mirror of
https://github.com/Inside4ndroid/TMDB-Embed-API.git
synced 2026-10-22 17:45:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ebb0d32d5 |
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [1.0.8] - 2025-10-03
|
||||
|
||||
### Added
|
||||
- Added `processStreamsForProxy` so the aggregate and provider-specific endpoints automatically rewrite stream URLs through the internal `/m3u8-proxy`/`/ts-proxy` layer whenever `enableProxy` is turned on, stripping provider headers safely.
|
||||
|
||||
### Changed
|
||||
- Showbox provider overhaul: filesystem-only caching (Redis removed), smarter FebBox cookie selection with region fallbacks, TMDB title/image validation that recognises romanized names, plus cached HEAD size lookups for faster listings.
|
||||
- Vixsrc provider rewritten to parse the `window.masterPlaylist` payload (token + expiry), returning a single master playlist with English subtitle lookup and correct Referer headers.
|
||||
- Config loader now prefers `utils/user-config.json`, dedupes TMDB keys, defaults provider enable flags to `true`, and scrubs legacy proxy env values when mirroring overrides back to `process.env`.
|
||||
- 4KHDHub provider permanently drops `.zip` archive links instead of trimming extensions and improves host distribution logging.
|
||||
- Provider registry dynamically enumerates available modules (removing MoviesClub/Xprime remnants) while keeping per-request cookie stats for the admin debug panel.
|
||||
|
||||
### Removed
|
||||
- Deleted legacy `providers/moviesclub.js`, `providers/xprime.js`, and other unused proxy/auth remnants that were no longer referenced.
|
||||
|
||||
### Fixed
|
||||
- Aggregated responses now obey the proxy flag without client changes—streams returned from `/api/streams/...` are proxy-wrapped and omit upstream header hints when `enableProxy` is active.
|
||||
|
||||
## [1.0.7] - 2025-09-21
|
||||
|
||||
### Added
|
||||
@@ -132,6 +150,7 @@ All notable changes to this project will be documented in this file.
|
||||
## [1.0.0] - 2025-09-16
|
||||
- Initial stable release.
|
||||
|
||||
[1.0.8]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.7...v1.0.8
|
||||
[1.0.7]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.6...v1.0.7
|
||||
[1.0.6]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.5...v1.0.6
|
||||
[1.0.5]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.4...v1.0.5
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<img src="https://img.shields.io/badge/Node.js-18%2B-brightgreen?style=flat" />
|
||||
<img src="https://img.shields.io/badge/Status-Active-success?style=flat" />
|
||||
<img src="https://img.shields.io/badge/License-MIT-blue?style=flat" />
|
||||
<img src="https://img.shields.io/badge/Version-1.0.7-informational?style=flat" />
|
||||
<img src="https://img.shields.io/badge/Version-1.0.8-informational?style=flat" />
|
||||
<img src="https://img.shields.io/docker/pulls/inside4ndroid/tmdb-embed-api?label=Docker%20Pulls&style=flat" />
|
||||
</p>
|
||||
|
||||
|
||||
+21
-1
@@ -3,14 +3,23 @@ const express = require('express');
|
||||
const cors = require('cors');
|
||||
const os = require('os');
|
||||
const { config, saveConfigPatch, OVERRIDE_PATH } = require('./utils/config');
|
||||
const { authenticate, issueSession, requireAuth, getSession, updatePassword, AUTH_FILE } = require('./utils/auth');
|
||||
const { authenticate, issueSession, requireAuth, getSession, updatePassword } = require('./utils/auth');
|
||||
const path = require('path');
|
||||
const { listProviders, getProvider, getCookieStats } = require('./providers/registry');
|
||||
const { createProxyRoutes, processStreamsForProxy } = require('./proxy/proxyServer');
|
||||
const { resolveImdbId } = require('./utils/tmdb');
|
||||
const { applyFilters } = require('./utils/streamFilters');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Conditionally mount proxy routes early so downstream handlers can use them
|
||||
if (config.enableProxy) {
|
||||
console.log('[startup] enableProxy flag active: mounting proxy routes');
|
||||
createProxyRoutes(app);
|
||||
} else {
|
||||
console.log('[startup] enableProxy flag disabled: proxy routes not mounted');
|
||||
}
|
||||
|
||||
// --- Simple In-Memory Rate Limiting for /auth/login ---
|
||||
const loginAttempts = new Map(); // key: ip, value: { count, first, last, lockedUntil }
|
||||
const MAX_ATTEMPTS_WINDOW = 5; // attempts allowed
|
||||
@@ -340,6 +349,12 @@ app.get('/api/streams/:type/:tmdbId', async (req,res) => {
|
||||
let streams = results.flat();
|
||||
streams = applyFilters(streams, 'aggregate', config.minQualities, config.excludeCodecs);
|
||||
metrics.streamsReturned += streams.length;
|
||||
if (config.enableProxy) {
|
||||
const serverUrl = `${req.protocol}://${req.get('host')}`;
|
||||
streams = processStreamsForProxy(streams, serverUrl);
|
||||
// Omit original headers when proxying to avoid leaking upstream requirements
|
||||
streams = streams.map(s => { if (s && typeof s === 'object') { const { headers, ...rest } = s; return rest; } return s; });
|
||||
}
|
||||
res.json({ success:true, tmdbId, imdbId, count: streams.length, providerTimings, streams });
|
||||
} catch (e) {
|
||||
metrics.lastError = e.message;
|
||||
@@ -366,6 +381,11 @@ app.get('/api/streams/:provider/:type/:tmdbId', async (req,res) => {
|
||||
const providerTimings = { [prov.name]: Date.now()-t0 };
|
||||
streams = applyFilters(streams, prov.name, config.minQualities, config.excludeCodecs);
|
||||
metrics.streamsReturned += streams.length;
|
||||
if (config.enableProxy) {
|
||||
const serverUrl = `${req.protocol}://${req.get('host')}`;
|
||||
streams = processStreamsForProxy(streams, serverUrl);
|
||||
streams = streams.map(s => { if (s && typeof s === 'object') { const { headers, ...rest } = s; return rest; } return s; });
|
||||
}
|
||||
res.json({ success:true, provider: prov.name, tmdbId, imdbId, count: streams.length, providerTimings, streams });
|
||||
} catch (e) {
|
||||
metrics.lastError = e.message;
|
||||
|
||||
Generated
+13
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "tmdb-embed-api",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tmdb-embed-api",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.8",
|
||||
"dependencies": {
|
||||
"aromanize": "^0.1.5",
|
||||
"axios": "^1.9.0",
|
||||
@@ -674,6 +674,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/axios-cookiejar-support/node_modules/undici": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
|
||||
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tmdb-embed-api",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.8",
|
||||
"description": "Standalone streaming embed source aggregation API (TMDB ID in, streams out)",
|
||||
"main": "apiServer.js",
|
||||
"scripts": {
|
||||
|
||||
+24
-22
@@ -770,7 +770,9 @@ function extractHubCloudLinks(url, referer) {
|
||||
headers = { Referer: baseUrl + '/', Origin: baseUrl };
|
||||
console.log('[4KHDHub] Added Referer/Origin headers for r2.dev FSL link');
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
|
||||
resolve({
|
||||
name: `4KHDHub - FSL Server${qualityLabel}`,
|
||||
@@ -794,7 +796,9 @@ function extractHubCloudLinks(url, referer) {
|
||||
headers = { Referer: baseUrl + '/', Origin: baseUrl };
|
||||
console.log('[4KHDHub] Added Referer/Origin headers for r2.dev FSL link (fallback)');
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
|
||||
resolve({
|
||||
name: `4KHDHub - FSL Server${qualityLabel}`,
|
||||
@@ -1332,24 +1336,18 @@ function extractStreamingLinks(downloadLinks) {
|
||||
|
||||
return Promise.all(promises)
|
||||
.then(results => {
|
||||
const validResults = results.filter(result => result !== null);
|
||||
const flatResults = validResults.flat();
|
||||
// Keep .zip links but strip the trailing .zip extension (attempt direct file URL)
|
||||
const transformedResults = flatResults
|
||||
.filter(link => link && link.url)
|
||||
.map(link => {
|
||||
try {
|
||||
const lower = link.url.toLowerCase();
|
||||
if (lower.endsWith('.zip')) {
|
||||
const newUrl = link.url.slice(0, -4); // remove .zip
|
||||
console.log(`[4KHDHub] Converted zip URL to non-zip: ${link.url} -> ${newUrl}`);
|
||||
return { ...link, url: newUrl };
|
||||
}
|
||||
} catch {}
|
||||
return link;
|
||||
});
|
||||
// Note: Link count will be logged after validation completes
|
||||
return transformedResults;
|
||||
const validResults = results.filter(r => r !== null).flat();
|
||||
const filtered = validResults.filter(link => {
|
||||
if (!link || !link.url) return false;
|
||||
const lower = link.url.toLowerCase();
|
||||
if (lower.endsWith('.zip')) {
|
||||
console.log(`[4KHDHub] Skipping archive (zip) link: ${link.url}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
console.log(`[4KHDHub] Post-processing: kept ${filtered.length}, skipped ${validResults.length - filtered.length} zip archive links`);
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1841,11 +1839,15 @@ async function get4KHDHubStreams(tmdbId, type, season = null, episode = null) {
|
||||
try {
|
||||
const h = new URL(s.url).hostname;
|
||||
acc[h] = (acc[h] || 0) + 1;
|
||||
} catch {}
|
||||
} catch {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
console.log('[4KHDHub] Final host distribution:', hostCounts);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
|
||||
console.log(`[4KHDHub] Returning ${streams.length} streams`);
|
||||
return streams;
|
||||
|
||||
@@ -2679,10 +2679,9 @@ const sortStreamsByQuality = (streams) => {
|
||||
|
||||
// Provider priority ordering (lower number = higher priority)
|
||||
const providerSortKeys = {
|
||||
'ShowBox': 1,
|
||||
'Xprime.tv': 2,
|
||||
'HollyMovieHD': 3,
|
||||
'Soaper TV': 4,
|
||||
'ShowBox': 1,
|
||||
'HollyMovieHD': 2,
|
||||
'Soaper TV': 3,
|
||||
// Default for unknown providers
|
||||
default: 99
|
||||
};
|
||||
|
||||
+46
-5
@@ -1,5 +1,43 @@
|
||||
const axios = require('axios');
|
||||
|
||||
// Confirmed AES-CBC decoder for VidZee encrypted link format.
|
||||
// Format (after initial atob in site script): ivBase64:cipherBase64
|
||||
// ivBase64 -> CryptoJS Base64 parsed to WordArray (IV)
|
||||
// Key: UTF-8 bytes of "qrincywincyspider" padded with nulls to 32 bytes (AES-256-CBC)
|
||||
// Cipher: base64 ciphertext, PKCS7 padding, mode CBC.
|
||||
let cryptoJs; // lazy load to avoid cost if not needed
|
||||
function decodeVidZeeToken(token, debug) {
|
||||
try {
|
||||
if (typeof token !== 'string') return null;
|
||||
if (/^https?:\/\//i.test(token)) return null; // already a URL
|
||||
// Expect pattern base64:base64 (iv:cipher)
|
||||
const raw = Buffer.from(token, 'base64').toString('utf8');
|
||||
if (!raw.includes(':')) return null;
|
||||
const [ivB64, cipherB64] = raw.split(':');
|
||||
if (!ivB64 || !cipherB64) return null;
|
||||
if (!cryptoJs) cryptoJs = require('crypto-js');
|
||||
const iv = cryptoJs.enc.Base64.parse(ivB64.trim());
|
||||
const keyStr = 'qrincywincyspider';
|
||||
// Pad key with nulls to 32 bytes
|
||||
const keyUtf8 = cryptoJs.enc.Utf8.parse(keyStr.padEnd(32, '\0'));
|
||||
const decrypted = cryptoJs.AES.decrypt(cipherB64.trim(), keyUtf8, {
|
||||
iv,
|
||||
mode: cryptoJs.mode.CBC,
|
||||
padding: cryptoJs.pad.Pkcs7
|
||||
}).toString(cryptoJs.enc.Utf8);
|
||||
if (!decrypted) return null;
|
||||
if (!/^https?:\/\//i.test(decrypted)) {
|
||||
if (debug) console.log('[VidZee] decrypted but not a URL', decrypted.slice(0,80));
|
||||
return null;
|
||||
}
|
||||
if (debug) console.log('[VidZee] AES decoded token', { tokenSnippet: token.slice(0, 24)+'...', url: decrypted.slice(0,120) });
|
||||
return decrypted.trim();
|
||||
} catch (e) {
|
||||
if (debug) console.log('[VidZee] AES decode error', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Removed unused parseArgs helper
|
||||
|
||||
const getVidZeeStreams = async (tmdbId, mediaType, seasonNum, episodeNum) => {
|
||||
@@ -73,20 +111,23 @@ const getVidZeeStreams = async (tmdbId, mediaType, seasonNum, episodeNum) => {
|
||||
}
|
||||
|
||||
const streams = apiSources.map(sourceItem => {
|
||||
// Prefer sourceItem.name as label, fallback to sourceItem.type, then 'VidZee Stream'
|
||||
const label = sourceItem.name || sourceItem.type || 'VidZee';
|
||||
// Ensure quality has 'p' if it's a resolution, or keep it as is
|
||||
let quality = String(label).match(/^\d+$/) ? `${label}p` : label;
|
||||
// Normalize non-numeric or ambiguous quality labels to a baseline so they survive minQuality filter
|
||||
if (!/(\d{3,4})p/.test(quality.toLowerCase())) {
|
||||
quality = '720p';
|
||||
}
|
||||
const language = sourceItem.language || sourceItem.lang;
|
||||
|
||||
let rawLink = sourceItem.link;
|
||||
const debug = process.env.VIDZEE_DEBUG === '1';
|
||||
const decoded = decodeVidZeeToken(rawLink, debug);
|
||||
if (decoded && /^https?:\/\//i.test(decoded)) {
|
||||
if (debug) console.log('[VidZee] decoded link', { beforeSample: rawLink.slice(0,40)+'...', after: decoded.slice(0,80) });
|
||||
rawLink = decoded;
|
||||
}
|
||||
return {
|
||||
name: `VidZee Server${sr} - ${quality} - ${language}`,
|
||||
title: `VidZee Server${sr} - ${quality} - ${language}`,
|
||||
url: sourceItem.link, // Use sourceItem.link for the URL
|
||||
url: rawLink,
|
||||
quality: quality,
|
||||
provider: "VidZee",
|
||||
headers: {
|
||||
|
||||
@@ -1,651 +0,0 @@
|
||||
// MoviesClub Provider
|
||||
// Extracts streams from moviesapi.club
|
||||
|
||||
const axios = require('axios');
|
||||
const JsUnpacker = require('../utils/jsunpack');
|
||||
const { config } = require('../utils/config');
|
||||
|
||||
function applyMoviesClubProxy(url) {
|
||||
try {
|
||||
const base = config.moviesclubProxy;
|
||||
if (!base || typeof base !== 'string') return url;
|
||||
// Ensure base ends with either = or a delimiter allowing raw append
|
||||
const proxied = base + encodeURIComponent(url);
|
||||
return proxied;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async function getMoviesClubStreams(tmdbId, mediaType, season, episode) {
|
||||
console.log(`[moviesclub] Fetching streams for ${mediaType} tmdbId=${tmdbId} season=${season} episode=${episode}`);
|
||||
|
||||
const streams = [];
|
||||
|
||||
try {
|
||||
// Build the URL based on media type
|
||||
let url;
|
||||
if (mediaType === 'movie') {
|
||||
url = `https://moviesapi.club/movie/${tmdbId}`;
|
||||
} else if (mediaType === 'tv' && season && episode) {
|
||||
url = `https://moviesapi.club/tv/${tmdbId}-${season}-${episode}`;
|
||||
} else {
|
||||
console.log('[moviesclub] Invalid parameters for TV show');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`[moviesclub] Fetching from: ${url}`);
|
||||
|
||||
// Fetch the main page
|
||||
const response = await axios.get(url, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1'
|
||||
}
|
||||
});
|
||||
|
||||
const html = response.data;
|
||||
console.log(`[moviesclub] Fetched ${html.length} characters`);
|
||||
|
||||
// Extract data-url from srvswitch-menu
|
||||
const dataUrlMatch = html.match(/data-url="([^"]+)"/);
|
||||
if (!dataUrlMatch) {
|
||||
console.log('[moviesclub] No data-url found in HTML');
|
||||
return [];
|
||||
}
|
||||
|
||||
const embedUrl = dataUrlMatch[1];
|
||||
console.log(`[moviesclub] Found embed URL: ${embedUrl}`);
|
||||
|
||||
// Check if this is a cdn.moviesapi.club embed URL with multiple servers
|
||||
if (embedUrl.includes('cdn.moviesapi.club')) {
|
||||
console.log('[moviesclub] Detected cdn.moviesapi.club embed URL, handling multiple servers');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle regular embed URLs (existing logic)
|
||||
return await handleSingleServer(embedUrl, mediaType, season, episode, tmdbId);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[moviesclub] Error:`, error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cdn.moviesapi.club URLs with multiple servers
|
||||
async function handleMultipleServers(embedUrl, mediaType, season, episode, tmdbId) {
|
||||
console.log(`[moviesclub] Handling multiple servers from: ${embedUrl}`);
|
||||
|
||||
const streams = [];
|
||||
|
||||
try {
|
||||
// Fetch the embed page
|
||||
const embedResponse = await axios.get(embedUrl, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
|
||||
const embedHtml = embedResponse.data;
|
||||
console.log(`[moviesclub] Fetched embed page: ${embedHtml.length} characters`);
|
||||
|
||||
// Extract server options from the HTML
|
||||
const serverMatches = embedHtml.match(/<div class="server"[^>]*data-hash="([^"]+)"[^>]*>([^<]+)<\/div>/gi);
|
||||
|
||||
if (!serverMatches || serverMatches.length === 0) {
|
||||
console.log('[moviesclub] No server options found, trying direct iframe extraction');
|
||||
// Try to extract the main iframe src
|
||||
const iframeMatch = embedHtml.match(/<iframe[^>]*src="([^"]*)"[^>]*>/i);
|
||||
if (iframeMatch) {
|
||||
const iframeSrc = iframeMatch[1];
|
||||
console.log(`[moviesclub] Found iframe src: ${iframeSrc}`);
|
||||
|
||||
// Try to decode if it's base64
|
||||
try {
|
||||
const decodedSrc = Buffer.from(iframeSrc.split('/').pop(), 'base64').toString('utf-8');
|
||||
console.log(`[moviesclub] Decoded iframe src: ${decodedSrc}`);
|
||||
|
||||
// If it's a URL, use it directly
|
||||
if (decodedSrc.startsWith('http')) {
|
||||
const rawUrl = decodedSrc;
|
||||
const stream = {
|
||||
name: `MoviesClub - Direct Stream`,
|
||||
title: `${mediaType === 'movie' ? 'Movie' : `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`} ${tmdbId} - MoviesClub Direct`,
|
||||
url: applyMoviesClubProxy(rawUrl),
|
||||
quality: extractQualityFromUrl(rawUrl) || '720p'
|
||||
};
|
||||
streams.push(stream);
|
||||
console.log(`[moviesclub] Added direct stream from iframe`);
|
||||
return streams;
|
||||
}
|
||||
} catch (decodeError) {
|
||||
console.log(`[moviesclub] Could not decode iframe src: ${decodeError.message}`);
|
||||
}
|
||||
}
|
||||
return streams;
|
||||
}
|
||||
|
||||
console.log(`[moviesclub] Found ${serverMatches.length} server options`);
|
||||
|
||||
// Process each server
|
||||
for (let i = 0; i < serverMatches.length; i++) {
|
||||
const serverMatch = serverMatches[i];
|
||||
const hashMatch = serverMatch.match(/data-hash="([^"]+)"/);
|
||||
const nameMatch = serverMatch.match(/>([^<]+)<\/div>/);
|
||||
|
||||
if (!hashMatch || !nameMatch) {
|
||||
console.log(`[moviesclub] Could not parse server ${i + 1}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const encodedHash = hashMatch[1];
|
||||
const serverName = nameMatch[1].trim();
|
||||
|
||||
console.log(`[moviesclub] Processing server ${i + 1}: ${serverName}`);
|
||||
|
||||
try {
|
||||
// Construct the rcp URL from the hash
|
||||
const rcpUrl = `https://cloudnestra.com/rcp/${encodedHash}`;
|
||||
console.log(`[moviesclub] RCP URL: ${rcpUrl}`);
|
||||
|
||||
// Fetch the rcp URL to get the JavaScript with prorcp URL
|
||||
const rcpResponse = await axios.get(rcpUrl, {
|
||||
timeout: 8000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': embedUrl
|
||||
}
|
||||
});
|
||||
|
||||
const rcpHtml = rcpResponse.data;
|
||||
console.log(`[moviesclub] RCP response length: ${rcpHtml.length}`);
|
||||
|
||||
// Extract prorcp URL from the JavaScript
|
||||
const prorcpMatch = rcpHtml.match(/src:\s*['"]([^'"]*prorcp[^'"]*)['"]/i);
|
||||
if (!prorcpMatch) {
|
||||
// Try alternative patterns
|
||||
const altMatch = rcpHtml.match(/['"](\/prorcp\/[^'"]*)['"]/i);
|
||||
if (altMatch) {
|
||||
prorcpUrl = altMatch[1];
|
||||
} else {
|
||||
console.log(`[moviesclub] No prorcp URL found in rcp response`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
prorcpUrl = prorcpMatch[1];
|
||||
}
|
||||
|
||||
console.log(`[moviesclub] Found prorcp URL: ${prorcpUrl}`);
|
||||
|
||||
// If it's a relative URL, make it absolute
|
||||
if (prorcpUrl.startsWith('/')) {
|
||||
prorcpUrl = `https://cloudnestra.com${prorcpUrl}`;
|
||||
console.log(`[moviesclub] Converted to absolute URL: ${prorcpUrl}`);
|
||||
}
|
||||
|
||||
// Fetch the prorcp URL to get the player HTML
|
||||
const prorcpResponse = await axios.get(prorcpUrl, {
|
||||
timeout: 8000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': rcpUrl
|
||||
}
|
||||
});
|
||||
|
||||
const prorcpHtml = prorcpResponse.data;
|
||||
console.log(`[moviesclub] Prorcp response length: ${prorcpHtml.length}`);
|
||||
|
||||
// Extract stream URL from Playerjs configuration
|
||||
const streamUrl = extractStreamFromPlayerjsUpdated(prorcpHtml);
|
||||
if (!streamUrl) {
|
||||
console.log(`[moviesclub] No stream URL found in prorcp response`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[moviesclub] Extracted stream URL: ${streamUrl.substring(0, 100)}...`);
|
||||
|
||||
// Create stream object
|
||||
const rawUrl = streamUrl;
|
||||
const stream = {
|
||||
name: `MoviesClub - ${serverName}`,
|
||||
title: `${mediaType === 'movie' ? 'Movie' : `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`} ${tmdbId} - ${serverName}`,
|
||||
url: applyMoviesClubProxy(rawUrl),
|
||||
quality: extractQualityFromUrl(rawUrl) || '720p',
|
||||
headers: {}
|
||||
};
|
||||
|
||||
streams.push(stream);
|
||||
console.log(`[moviesclub] Added stream from server: ${serverName}`);
|
||||
|
||||
} catch (serverError) {
|
||||
console.error(`[moviesclub] Error processing server ${serverName}:`, serverError.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[moviesclub] Total streams found: ${streams.length}`);
|
||||
return streams;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[moviesclub] Error in handleMultipleServers:`, error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle regular embed URLs (existing logic)
|
||||
async function handleSingleServer(embedUrl, mediaType, season, episode, tmdbId) {
|
||||
console.log(`[moviesclub] Handling single server from: ${embedUrl}`);
|
||||
|
||||
const streams = [];
|
||||
|
||||
try {
|
||||
// Fetch the embed page
|
||||
const embedResponse = await axios.get(embedUrl, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
|
||||
const embedHtml = embedResponse.data;
|
||||
console.log(`[moviesclub] Fetched embed page: ${embedHtml.length} characters`);
|
||||
|
||||
// Look for packed JavaScript using JsUnpacker.detect()
|
||||
const scriptTags = embedHtml.match(/<script[^>]*>[\s\S]*?<\/script>/gi) || [];
|
||||
let packedCode = null;
|
||||
|
||||
for (const scriptTag of scriptTags) {
|
||||
// Extract script content
|
||||
const scriptContent = scriptTag.replace(/<\/?script[^>]*>/gi, '').trim();
|
||||
|
||||
if (scriptContent) {
|
||||
// Create unpacker instance and check if it's packed
|
||||
const unpacker = new JsUnpacker(scriptContent);
|
||||
|
||||
if (unpacker.detect()) {
|
||||
console.log(`[moviesclub] Found packed JavaScript in script tag, length: ${scriptContent.length}`);
|
||||
packedCode = scriptContent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!packedCode) {
|
||||
console.log('[moviesclub] No packed JavaScript found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Unpack using JsUnpacker
|
||||
const unpacker = new JsUnpacker(packedCode);
|
||||
const unpackedCode = unpacker.unpack();
|
||||
|
||||
if (!unpackedCode) {
|
||||
console.log('[moviesclub] Failed to unpack JavaScript');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`[moviesclub] Successfully unpacked code, length: ${unpackedCode.length}`);
|
||||
|
||||
// Parse the player configuration from unpacked code
|
||||
const playerConfig = parsePlayerConfig(unpackedCode);
|
||||
if (!playerConfig) {
|
||||
console.log('[moviesclub] Failed to parse player configuration');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('[moviesclub] Parsed player config:', playerConfig);
|
||||
|
||||
// Extract streams from the configuration
|
||||
if (playerConfig.file) {
|
||||
const rawUrl = playerConfig.file;
|
||||
const stream = {
|
||||
name: `MoviesClub - ${playerConfig.title || 'Server 1'}`,
|
||||
title: playerConfig.title || `${mediaType === 'movie' ? 'Movie' : `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`} ${tmdbId} - MoviesClub`,
|
||||
url: applyMoviesClubProxy(rawUrl),
|
||||
quality: extractQualityFromUrl(rawUrl) || '720p',
|
||||
// Always include vidora headers for single-server results to prevent 403 playback issues
|
||||
headers: {
|
||||
Origin: 'https://vidora.stream',
|
||||
Referer: 'https://vidora.stream/'
|
||||
}
|
||||
};
|
||||
|
||||
// Add poster if available
|
||||
if (playerConfig.poster) {
|
||||
stream.poster = playerConfig.poster;
|
||||
}
|
||||
|
||||
// Add subtitles if available
|
||||
if (playerConfig.subtitle) {
|
||||
stream.subtitles = parseSubtitles(playerConfig.subtitle);
|
||||
}
|
||||
|
||||
streams.push(stream);
|
||||
}
|
||||
|
||||
console.log(`[moviesclub] Found ${streams.length} streams`);
|
||||
return streams;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[moviesclub] Error in handleSingleServer:`, error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract stream URL from Playerjs configuration in HTML
|
||||
function extractStreamFromPlayerjs(html) {
|
||||
try {
|
||||
// Look for the Playerjs constructor call
|
||||
const playerMatch = html.match(/new Playerjs\(\s*(\{[^}]*\})\s*\)/);
|
||||
if (!playerMatch) {
|
||||
console.log('[moviesclub] No Playerjs constructor found in HTML');
|
||||
return null;
|
||||
}
|
||||
|
||||
const configStr = playerMatch[1];
|
||||
console.log(`[moviesclub] Found Playerjs config: ${configStr.substring(0, 200)}...`);
|
||||
|
||||
// Extract the file URL from the configuration
|
||||
const fileMatch = configStr.match(/file:\s*['"]([^'"]+)['"]/);
|
||||
if (!fileMatch) {
|
||||
console.log('[moviesclub] No file URL found in Playerjs config');
|
||||
return null;
|
||||
}
|
||||
|
||||
const streamUrl = fileMatch[1];
|
||||
console.log(`[moviesclub] Extracted stream URL from Playerjs: ${streamUrl.substring(0, 100)}...`);
|
||||
|
||||
return streamUrl;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[moviesclub] Error extracting stream from Playerjs:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract streams from server response
|
||||
function extractStreamsFromServer(serverData, serverName, mediaType, season, episode, tmdbId) {
|
||||
const streams = [];
|
||||
|
||||
try {
|
||||
// Handle different response formats
|
||||
if (typeof serverData === 'string') {
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
serverData = JSON.parse(serverData);
|
||||
} catch (parseError) {
|
||||
console.log(`[moviesclub] Server response is not JSON, treating as direct URL: ${serverData.substring(0, 100)}`);
|
||||
// Treat as direct stream URL
|
||||
if (serverData.startsWith('http')) {
|
||||
const rawUrl = serverData;
|
||||
const stream = {
|
||||
name: `MoviesClub - ${serverName}`,
|
||||
title: `${mediaType === 'movie' ? 'Movie' : `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`} ${tmdbId} - ${serverName}`,
|
||||
url: applyMoviesClubProxy(rawUrl),
|
||||
quality: extractQualityFromUrl(rawUrl) || '720p',
|
||||
headers: serverData.includes('vidora.stream') ? { Origin: 'https://vidora.stream', Referer: 'https://vidora.stream/' } : {}
|
||||
};
|
||||
streams.push(stream);
|
||||
}
|
||||
return streams;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle JSON response
|
||||
if (serverData && typeof serverData === 'object') {
|
||||
// Look for common stream URL patterns
|
||||
const possibleKeys = ['file', 'url', 'src', 'stream', 'video', 'source'];
|
||||
|
||||
for (const key of possibleKeys) {
|
||||
if (serverData[key] && typeof serverData[key] === 'string' && serverData[key].startsWith('http')) {
|
||||
console.log(`[moviesclub] Found stream URL in key '${key}': ${serverData[key].substring(0, 100)}`);
|
||||
|
||||
const rawUrl = serverData[key];
|
||||
const stream = {
|
||||
name: `MoviesClub - ${serverName}`,
|
||||
title: `${mediaType === 'movie' ? 'Movie' : `S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`} ${tmdbId} - ${serverName}`,
|
||||
url: applyMoviesClubProxy(rawUrl),
|
||||
quality: extractQualityFromUrl(rawUrl) || '720p',
|
||||
headers: rawUrl.includes('vidora.stream') ? { Origin: 'https://vidora.stream', Referer: 'https://vidora.stream/' } : {}
|
||||
};
|
||||
|
||||
// Add poster if available
|
||||
if (serverData.poster) {
|
||||
stream.poster = serverData.poster;
|
||||
}
|
||||
|
||||
// Add subtitles if available
|
||||
if (serverData.subtitle || serverData.subtitles) {
|
||||
const subtitleData = serverData.subtitle || serverData.subtitles;
|
||||
stream.subtitles = parseSubtitles(subtitleData);
|
||||
}
|
||||
|
||||
streams.push(stream);
|
||||
break; // Only take the first valid stream URL
|
||||
}
|
||||
}
|
||||
|
||||
// If no direct URL found, check for nested objects
|
||||
if (streams.length === 0) {
|
||||
for (const key of possibleKeys) {
|
||||
if (serverData[key] && typeof serverData[key] === 'object') {
|
||||
const nestedStreams = extractStreamsFromServer(serverData[key], serverName, mediaType, season, episode, tmdbId);
|
||||
streams.push(...nestedStreams);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[moviesclub] Error extracting streams from server ${serverName}:`, error.message);
|
||||
}
|
||||
|
||||
return streams;
|
||||
}
|
||||
|
||||
// Extract stream URL from Playerjs configuration in HTML
|
||||
function extractStreamFromPlayerjs(html) {
|
||||
try {
|
||||
// Look for the Playerjs constructor call
|
||||
const playerMatch = html.match(/new Playerjs\(\s*(\{[^}]*\})\s*\)/);
|
||||
if (!playerMatch) {
|
||||
console.log('[moviesclub] No Playerjs constructor found in HTML');
|
||||
return null;
|
||||
}
|
||||
|
||||
const configStr = playerMatch[1];
|
||||
console.log(`[moviesclub] Found Playerjs config: ${configStr.substring(0, 200)}...`);
|
||||
|
||||
// Extract the file URL from the configuration
|
||||
const fileMatch = configStr.match(/file:\s*['"]([^'"]+)['"]/);
|
||||
if (!fileMatch) {
|
||||
console.log('[moviesclub] No file URL found in Playerjs config');
|
||||
return null;
|
||||
}
|
||||
|
||||
const streamUrl = fileMatch[1];
|
||||
console.log(`[moviesclub] Extracted stream URL from Playerjs: ${streamUrl.substring(0, 100)}...`);
|
||||
|
||||
return streamUrl;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[moviesclub] Error extracting stream from Playerjs:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse player configuration from unpacked JavaScript
|
||||
function parsePlayerConfig(unpackedCode) {
|
||||
try {
|
||||
// Look for the player configuration object
|
||||
const configMatch = unpackedCode.match(/new Playerjs\(\s*(\{[^}]*\})\s*\)/);
|
||||
if (!configMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the configuration object
|
||||
const configStr = configMatch[1];
|
||||
|
||||
// Parse the configuration (this is a simple parser for the expected format)
|
||||
const config = {};
|
||||
|
||||
// Extract key-value pairs
|
||||
const pairs = configStr.match(/(\w+):"([^"]*)"/g) || [];
|
||||
pairs.forEach(pair => {
|
||||
const [, key, value] = pair.match(/(\w+):"([^"]*)"/);
|
||||
config[key] = value;
|
||||
});
|
||||
|
||||
// Also handle non-quoted values
|
||||
const numberPairs = configStr.match(/(\w+):(\d+(?:\.\d+)?)/g) || [];
|
||||
numberPairs.forEach(pair => {
|
||||
const [, key, value] = pair.match(/(\w+):(\d+(?:\.\d+)?)/);
|
||||
config[key] = parseFloat(value);
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error('[moviesclub] Error parsing player config:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse subtitles from the subtitle string
|
||||
function parseSubtitles(subtitleStr) {
|
||||
if (!subtitleStr) return [];
|
||||
|
||||
const subtitles = [];
|
||||
const subtitleMatches = subtitleStr.match(/\[([^\]]+)\]([^,\[]*)/g);
|
||||
|
||||
if (subtitleMatches) {
|
||||
subtitleMatches.forEach(match => {
|
||||
const [, language, url] = match.match(/\[([^\]]+)\]([^,\[]*)/);
|
||||
if (url && url.trim()) {
|
||||
subtitles.push({
|
||||
language: language.trim(),
|
||||
url: url.trim(),
|
||||
type: 'vtt'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
// Extract quality from URL
|
||||
function extractQualityFromUrl(url) {
|
||||
if (!url) return '720p';
|
||||
|
||||
const qualityPatterns = [
|
||||
{ pattern: /1080p|1080/i, quality: '1080p' },
|
||||
{ pattern: /720p|720/i, quality: '720p' },
|
||||
{ pattern: /480p|480/i, quality: '480p' },
|
||||
{ pattern: /360p|360/i, quality: '360p' },
|
||||
{ pattern: /2160p|4k|uhd/i, quality: '2160p' }
|
||||
];
|
||||
|
||||
for (const { pattern, quality } of qualityPatterns) {
|
||||
if (pattern.test(url)) {
|
||||
return quality;
|
||||
}
|
||||
}
|
||||
|
||||
return '720p';
|
||||
}
|
||||
|
||||
// Extract stream URL from Playerjs configuration in HTML (updated version)
|
||||
function extractStreamFromPlayerjsUpdated(html) {
|
||||
try {
|
||||
// Look for the Playerjs constructor call
|
||||
const playerMatch = html.match(/new Playerjs\(\s*(\{[^}]*\})\s*\)/);
|
||||
if (!playerMatch) {
|
||||
console.log('[moviesclub] No Playerjs constructor found in HTML');
|
||||
return null;
|
||||
}
|
||||
|
||||
const configStr = playerMatch[1];
|
||||
console.log(`[moviesclub] Found Playerjs config: ${configStr.substring(0, 200)}...`);
|
||||
|
||||
// Extract the file URL from the configuration
|
||||
let streamUrl = null;
|
||||
|
||||
// Try multiple patterns to extract the file URL
|
||||
const patterns = [
|
||||
/file:\s*['"]([^'"]+)['"]/,
|
||||
/file:\s*([^,\s}]+)/,
|
||||
/file\s*:\s*['"]([^'"]+)['"]/,
|
||||
/file\s*:\s*([^,\s}]+)/
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = configStr.match(pattern);
|
||||
if (match && match[1] && match[1].trim() !== '' && match[1].trim() !== '""') {
|
||||
streamUrl = match[1].replace(/['"]/g, '').trim();
|
||||
console.log(`[moviesclub] Found stream URL with pattern: ${streamUrl.substring(0, 100)}...`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no URL found in config, look for streaming URLs in the HTML
|
||||
if (!streamUrl) {
|
||||
console.log('[moviesclub] No file URL in Playerjs config, searching HTML for streaming URLs...');
|
||||
|
||||
// Look for m3u8 URLs
|
||||
const m3u8Match = html.match(/https?:\/\/[^'"\s]+\.m3u8[^'"\s]*/i);
|
||||
if (m3u8Match) {
|
||||
streamUrl = m3u8Match[0];
|
||||
console.log(`[moviesclub] Found m3u8 URL in HTML: ${streamUrl.substring(0, 100)}...`);
|
||||
}
|
||||
|
||||
// Look for other streaming URLs
|
||||
if (!streamUrl) {
|
||||
const streamMatch = html.match(/https?:\/\/[^'"\s]+(?:\.mp4|\.m3u8|\.ts)[^'"\s]*/i);
|
||||
if (streamMatch) {
|
||||
streamUrl = streamMatch[0];
|
||||
console.log(`[moviesclub] Found streaming URL in HTML: ${streamUrl.substring(0, 100)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!streamUrl) {
|
||||
console.log('[moviesclub] No file URL found in Playerjs config after trying all patterns');
|
||||
console.log('[moviesclub] Config string:', configStr.substring(0, 500));
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[moviesclub] Extracted stream URL from Playerjs: ${streamUrl.substring(0, 100)}...`);
|
||||
|
||||
return streamUrl;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[moviesclub] Error extracting stream from Playerjs:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMoviesClubStreams
|
||||
};
|
||||
@@ -13,9 +13,7 @@ const providerFunctionMap = {
|
||||
'MP4Hydra.js': 'getMP4HydraStreams',
|
||||
'VidZee.js': 'getVidZeeStreams',
|
||||
'vixsrc.js': 'getVixsrcStreams',
|
||||
'xprime.js': 'getXprimeStreams',
|
||||
'uhdmovies.js': 'getUHDMoviesStreams',
|
||||
'moviesclub.js': 'getMoviesClubStreams',
|
||||
};
|
||||
|
||||
// Stats for debug endpoint
|
||||
@@ -92,46 +90,6 @@ function createFetchFunction(providerInfo) {
|
||||
lastCookieStats.remainingMB = global.currentRequestUserCookieRemainingMB;
|
||||
}
|
||||
global.currentRequestConfig = previousConfig || {};
|
||||
} else if (providerInfo.name === 'xprime') {
|
||||
// Special case for Xprime with TMDB metadata lookup
|
||||
const { getTmdbApiKey } = require('../utils/tmdbKey');
|
||||
const tmdbApiKey = getTmdbApiKey();
|
||||
if (!tmdbApiKey) {
|
||||
console.warn('[registry] xprime skipped: TMDB API key missing');
|
||||
return [];
|
||||
}
|
||||
// Fetch minimal TMDB metadata for title + year
|
||||
let title = null; let year = null;
|
||||
try {
|
||||
const axios = require('axios');
|
||||
const metaUrl = `https://api.themoviedb.org/3/${mediaType}/${ctx.tmdbId}?api_key=${tmdbApiKey}`;
|
||||
const { data: meta } = await axios.get(metaUrl, { timeout: 8000 });
|
||||
title = mediaType === 'movie' ? (meta.title || meta.original_title) : (meta.name || meta.original_name);
|
||||
const dateStr = mediaType === 'movie' ? meta.release_date : meta.first_air_date;
|
||||
if (dateStr) year = dateStr.split('-')[0];
|
||||
} catch (mErr) {
|
||||
console.warn('[registry] xprime metadata fetch failed:', mErr.message);
|
||||
return [];
|
||||
}
|
||||
if (!title || !year) {
|
||||
console.log('[registry] xprime missing title/year after TMDB lookup');
|
||||
return [];
|
||||
}
|
||||
result = await module[funcName](title, year, mediaType, ctx.season || null, ctx.episode || null);
|
||||
// Normalize quality labels
|
||||
if (Array.isArray(result)) {
|
||||
result = result.map(s => {
|
||||
let q = s.quality || 'Unknown';
|
||||
if (!/(\d{3,4})p/.test(q.toLowerCase())) {
|
||||
const qLower = q.toLowerCase();
|
||||
if (qLower.includes('fhd') || qLower.includes('full')) q = '1080p';
|
||||
else if (qLower === 'hd' || qLower.includes('720')) q = '720p';
|
||||
else if (qLower.includes('sd') || qLower.includes('480')) q = '480p';
|
||||
else if (qLower.includes('cam')) q = '360p';
|
||||
}
|
||||
return { ...s, quality: q, name: s.name || 'Xprime', provider: 'xprime' };
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Standard provider call
|
||||
result = await module[funcName](ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
|
||||
|
||||
+123
-124
@@ -6,7 +6,8 @@
|
||||
// Constants
|
||||
const TMDB_API_KEY = "68e094699525b18a70bab2f86b1fa706";
|
||||
const BASE_URL = 'https://vixsrc.to';
|
||||
|
||||
let vixsrcUrl;
|
||||
let subtitleApiUrl;
|
||||
// Helper function to make HTTP requests with default headers
|
||||
function makeRequest(url, options = {}) {
|
||||
const defaultHeaders = {
|
||||
@@ -23,24 +24,24 @@ function makeRequest(url, options = {}) {
|
||||
headers: defaultHeaders,
|
||||
...options
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`[Vixsrc] Request failed for ${url}: ${error.message}`);
|
||||
throw error;
|
||||
});
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`[Vixsrc] Request failed for ${url}: ${error.message}`);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to get TMDB info
|
||||
function getTmdbInfo(tmdbId, mediaType) {
|
||||
const url = `https://api.themoviedb.org/3/${mediaType === 'tv' ? 'tv' : 'movie'}/${tmdbId}?api_key=${TMDB_API_KEY}`;
|
||||
|
||||
|
||||
return makeRequest(url)
|
||||
.then(response => response.json())
|
||||
.then(response => response.json())
|
||||
.then(data => ({ data }));
|
||||
}
|
||||
|
||||
@@ -51,8 +52,6 @@ function getTmdbInfo(tmdbId, mediaType) {
|
||||
|
||||
// Helper function to extract stream URL from Vixsrc page
|
||||
function extractStreamFromPage(url, contentType, contentId, seasonNum, episodeNum) {
|
||||
let vixsrcUrl;
|
||||
let subtitleApiUrl;
|
||||
|
||||
if (contentType === 'movie') {
|
||||
vixsrcUrl = `${BASE_URL}/movie/${contentId}`;
|
||||
@@ -64,152 +63,152 @@ function extractStreamFromPage(url, contentType, contentId, seasonNum, episodeNu
|
||||
|
||||
console.log(`[Vixsrc] Fetching: ${vixsrcUrl}`);
|
||||
|
||||
return makeRequest(vixsrcUrl, {
|
||||
return makeRequest(vixsrcUrl, {
|
||||
headers: {
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
||||
}
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
console.log(`[Vixsrc] HTML length: ${html.length} characters`);
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
console.log(`[Vixsrc] HTML length: ${html.length} characters`);
|
||||
|
||||
let masterPlaylistUrl = null;
|
||||
let masterPlaylistUrl = null;
|
||||
|
||||
// Method 1: Look for window.masterPlaylist (primary method)
|
||||
if (html.includes('window.masterPlaylist')) {
|
||||
console.log('[Vixsrc] Found window.masterPlaylist');
|
||||
// Method 1: Look for window.masterPlaylist (primary method)
|
||||
if (html.includes('window.masterPlaylist')) {
|
||||
console.log('[Vixsrc] Found window.masterPlaylist');
|
||||
|
||||
const urlMatch = html.match(/url:\s*['"]([^'"]+)['"]/);
|
||||
const tokenMatch = html.match(/['"]?token['"]?\s*:\s*['"]([^'"]+)['"]/);
|
||||
const expiresMatch = html.match(/['"]?expires['"]?\s*:\s*['"]([^'"]+)['"]/);
|
||||
const urlMatch = html.match(/url:\s*['"]([^'"]+)['"]/);
|
||||
const tokenMatch = html.match(/['"]?token['"]?\s*:\s*['"]([^'"]+)['"]/);
|
||||
const expiresMatch = html.match(/['"]?expires['"]?\s*:\s*['"]([^'"]+)['"]/);
|
||||
|
||||
if (urlMatch && tokenMatch && expiresMatch) {
|
||||
const baseUrl = urlMatch[1];
|
||||
const token = tokenMatch[1];
|
||||
const expires = expiresMatch[1];
|
||||
if (urlMatch && tokenMatch && expiresMatch) {
|
||||
const baseUrl = urlMatch[1];
|
||||
const token = tokenMatch[1];
|
||||
const expires = expiresMatch[1];
|
||||
|
||||
console.log('[Vixsrc] Extracted tokens:');
|
||||
console.log(` - Base URL: ${baseUrl}`);
|
||||
console.log(` - Token: ${token.substring(0, 20)}...`);
|
||||
console.log(` - Expires: ${expires}`);
|
||||
console.log('[Vixsrc] Extracted tokens:');
|
||||
console.log(` - Base URL: ${baseUrl}`);
|
||||
console.log(` - Token: ${token.substring(0, 20)}...`);
|
||||
console.log(` - Expires: ${expires}`);
|
||||
|
||||
// Construct the master playlist URL
|
||||
if (baseUrl.includes('?b=1')) {
|
||||
masterPlaylistUrl = `${baseUrl}&token=${token}&expires=${expires}&h=1&lang=en`;
|
||||
} else {
|
||||
masterPlaylistUrl = `${baseUrl}?token=${token}&expires=${expires}&h=1&lang=en`;
|
||||
// Construct the master playlist URL
|
||||
if (baseUrl.includes('?b=1')) {
|
||||
masterPlaylistUrl = `${baseUrl}&token=${token}&expires=${expires}&h=1&lang=en`;
|
||||
} else {
|
||||
masterPlaylistUrl = `${baseUrl}?token=${token}&expires=${expires}&h=1&lang=en`;
|
||||
}
|
||||
|
||||
console.log(`[Vixsrc] Constructed master playlist URL: ${masterPlaylistUrl}`);
|
||||
}
|
||||
|
||||
console.log(`[Vixsrc] Constructed master playlist URL: ${masterPlaylistUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Look for direct .m3u8 URLs
|
||||
if (!masterPlaylistUrl) {
|
||||
const m3u8Match = html.match(/(https?:\/\/[^'"\s]+\.m3u8[^'"\s]*)/);
|
||||
if (m3u8Match) {
|
||||
masterPlaylistUrl = m3u8Match[1];
|
||||
console.log('[Vixsrc] Found direct .m3u8 URL:', masterPlaylistUrl);
|
||||
// Method 2: Look for direct .m3u8 URLs
|
||||
if (!masterPlaylistUrl) {
|
||||
const m3u8Match = html.match(/(https?:\/\/[^'"\s]+\.m3u8[^'"\s]*)/);
|
||||
if (m3u8Match) {
|
||||
masterPlaylistUrl = m3u8Match[1];
|
||||
console.log('[Vixsrc] Found direct .m3u8 URL:', masterPlaylistUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Look for stream URLs in script tags
|
||||
if (!masterPlaylistUrl) {
|
||||
const scriptMatches = html.match(/<script[^>]*>(.*?)<\/script>/gs);
|
||||
if (scriptMatches) {
|
||||
for (const script of scriptMatches) {
|
||||
const streamMatch = script.match(/['"]?(https?:\/\/[^'"\s]+(?:\.m3u8|playlist)[^'"\s]*)/);
|
||||
if (streamMatch) {
|
||||
masterPlaylistUrl = streamMatch[1];
|
||||
console.log('[Vixsrc] Found stream in script:', masterPlaylistUrl);
|
||||
break;
|
||||
// Method 3: Look for stream URLs in script tags
|
||||
if (!masterPlaylistUrl) {
|
||||
const scriptMatches = html.match(/<script[^>]*>(.*?)<\/script>/gs);
|
||||
if (scriptMatches) {
|
||||
for (const script of scriptMatches) {
|
||||
const streamMatch = script.match(/['"]?(https?:\/\/[^'"\s]+(?:\.m3u8|playlist)[^'"\s]*)/);
|
||||
if (streamMatch) {
|
||||
masterPlaylistUrl = streamMatch[1];
|
||||
console.log('[Vixsrc] Found stream in script:', masterPlaylistUrl);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!masterPlaylistUrl) {
|
||||
console.log('[Vixsrc] No master playlist URL found');
|
||||
return null;
|
||||
}
|
||||
if (!masterPlaylistUrl) {
|
||||
console.log('[Vixsrc] No master playlist URL found');
|
||||
return null;
|
||||
}
|
||||
|
||||
return { masterPlaylistUrl, subtitleApiUrl };
|
||||
});
|
||||
return { masterPlaylistUrl, subtitleApiUrl };
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to get subtitles
|
||||
function getSubtitles(subtitleApiUrl) {
|
||||
return makeRequest(subtitleApiUrl)
|
||||
.then(response => response.json())
|
||||
.then(subtitleData => {
|
||||
// Find English subtitle track (same logic as original)
|
||||
let subtitleTrack = subtitleData.find(track =>
|
||||
track.display.includes('English') && (track.encoding === 'ASCII' || track.encoding === 'UTF-8')
|
||||
);
|
||||
.then(response => response.json())
|
||||
.then(subtitleData => {
|
||||
// Find English subtitle track (same logic as original)
|
||||
let subtitleTrack = subtitleData.find(track =>
|
||||
track.display.includes('English') && (track.encoding === 'ASCII' || track.encoding === 'UTF-8')
|
||||
);
|
||||
|
||||
if (!subtitleTrack) {
|
||||
subtitleTrack = subtitleData.find(track => track.display.includes('English') && track.encoding === 'CP1252');
|
||||
}
|
||||
if (!subtitleTrack) {
|
||||
subtitleTrack = subtitleData.find(track => track.display.includes('English') && track.encoding === 'CP1252');
|
||||
}
|
||||
|
||||
if (!subtitleTrack) {
|
||||
subtitleTrack = subtitleData.find(track => track.display.includes('English') && track.encoding === 'CP1250');
|
||||
}
|
||||
if (!subtitleTrack) {
|
||||
subtitleTrack = subtitleData.find(track => track.display.includes('English') && track.encoding === 'CP1250');
|
||||
}
|
||||
|
||||
if (!subtitleTrack) {
|
||||
subtitleTrack = subtitleData.find(track => track.display.includes('English') && track.encoding === 'CP850');
|
||||
}
|
||||
if (!subtitleTrack) {
|
||||
subtitleTrack = subtitleData.find(track => track.display.includes('English') && track.encoding === 'CP850');
|
||||
}
|
||||
|
||||
const subtitles = subtitleTrack ? subtitleTrack.url : '';
|
||||
console.log(subtitles ? `[Vixsrc] Found subtitles: ${subtitles}` : '[Vixsrc] No English subtitles found');
|
||||
return subtitles;
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('[Vixsrc] Subtitle fetch failed:', error.message);
|
||||
return '';
|
||||
});
|
||||
const subtitles = subtitleTrack ? subtitleTrack.url : '';
|
||||
console.log(subtitles ? `[Vixsrc] Found subtitles: ${subtitles}` : '[Vixsrc] No English subtitles found');
|
||||
return subtitles;
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('[Vixsrc] Subtitle fetch failed:', error.message);
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
// Main function to get streams
|
||||
function getVixsrcStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
||||
console.log(`[Vixsrc] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
||||
|
||||
|
||||
return getTmdbInfo(tmdbId, mediaType)
|
||||
.then(() => extractStreamFromPage(null, mediaType, tmdbId, seasonNum, episodeNum))
|
||||
.then(streamData => {
|
||||
if (!streamData) {
|
||||
console.log('[Vixsrc] No stream data found');
|
||||
.then(() => extractStreamFromPage(null, mediaType, tmdbId, seasonNum, episodeNum))
|
||||
.then(streamData => {
|
||||
if (!streamData) {
|
||||
console.log('[Vixsrc] No stream data found');
|
||||
return [];
|
||||
}
|
||||
|
||||
const { masterPlaylistUrl, subtitleApiUrl } = streamData;
|
||||
|
||||
// Return single master playlist with Auto quality
|
||||
console.log('[Vixsrc] Returning master playlist with Auto quality...');
|
||||
|
||||
// Get subtitles
|
||||
return getSubtitles(subtitleApiUrl)
|
||||
.then(subtitles => {
|
||||
// Return single stream with master playlist
|
||||
const tmdbStreams = [{
|
||||
name: "Vixsrc",
|
||||
title: "Auto Quality Stream",
|
||||
url: masterPlaylistUrl,
|
||||
quality: '1080p',
|
||||
provider: 'Vidsrc',
|
||||
headers: {
|
||||
'Referer': vixsrcUrl,
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
}
|
||||
}];
|
||||
|
||||
console.log('[Vixsrc] Successfully processed 1 stream with Auto quality');
|
||||
return tmdbStreams;
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`[Vixsrc] Error in getVixsrcStreams: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const { masterPlaylistUrl, subtitleApiUrl } = streamData;
|
||||
|
||||
// Return single master playlist with Auto quality
|
||||
console.log('[Vixsrc] Returning master playlist with Auto quality...');
|
||||
|
||||
// Get subtitles
|
||||
return getSubtitles(subtitleApiUrl)
|
||||
.then(subtitles => {
|
||||
// Return single stream with master playlist
|
||||
const tmdbStreams = [{
|
||||
name: "Vixsrc",
|
||||
title: "Auto Quality Stream",
|
||||
url: masterPlaylistUrl,
|
||||
quality: '1080p',
|
||||
provider: 'Vidsrc',
|
||||
headers: {
|
||||
'Referer': BASE_URL,
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
}
|
||||
}];
|
||||
|
||||
console.log('[Vixsrc] Successfully processed 1 stream with Auto quality');
|
||||
return tmdbStreams;
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`[Vixsrc] Error in getVixsrcStreams: ${error.message}`);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { getVixsrcStreams };
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
// xprime.js (simplified & cleaned)
|
||||
require('dotenv').config();
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const RETRY_DELAY_MS_XPRIME = 1000;
|
||||
const MAX_RETRIES_XPRIME = 3;
|
||||
|
||||
// Cache directory (Vercel ephemeral vs local)
|
||||
const CACHE_DIR = process.env.VERCEL ? path.join('/tmp', '.cache') : path.join(__dirname, '.cache');
|
||||
|
||||
async function ensureCacheDir(dirPath) {
|
||||
try { await fs.mkdir(dirPath, { recursive: true }); } catch (_) {
|
||||
// swallow errors creating cache dir (ephemeral env or permission)
|
||||
}
|
||||
}
|
||||
|
||||
async function getFromCache(cacheKey, subDir = '') {
|
||||
if (process.env.DISABLE_CACHE === 'true') return null;
|
||||
try {
|
||||
const fullDir = path.join(CACHE_DIR, subDir);
|
||||
const filePath = path.join(fullDir, cacheKey);
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
return data;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
async function saveToCache(cacheKey, data, subDir = '') {
|
||||
if (process.env.DISABLE_CACHE === 'true') return;
|
||||
try {
|
||||
const fullDir = path.join(CACHE_DIR, subDir);
|
||||
await fs.mkdir(fullDir, { recursive: true });
|
||||
const filePath = path.join(fullDir, cacheKey);
|
||||
await fs.writeFile(filePath, data, 'utf8');
|
||||
} catch (_) {
|
||||
// ignore cache write errors (non-fatal)
|
||||
}
|
||||
}
|
||||
|
||||
const BROWSER_HEADERS_XPRIME = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Sec-Ch-Ua': '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
|
||||
'Sec-Ch-Ua-Mobile': '?0',
|
||||
'Sec-Ch-Ua-Platform': '"Windows"',
|
||||
'Connection': 'keep-alive'
|
||||
};
|
||||
|
||||
async function fetchWithRetry(url, options, maxRetries = MAX_RETRIES_XPRIME) {
|
||||
const { default: fetch } = await import('node-fetch');
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise(r => setTimeout(r, RETRY_DELAY_MS_XPRIME * Math.pow(2, attempt - 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function fetchStreamSize(url) {
|
||||
const subDir = 'xprime_stream_sizes';
|
||||
const hash = crypto.createHash('md5').update(url).digest('hex');
|
||||
const key = `${hash}.txt`;
|
||||
const cached = await getFromCache(key, subDir);
|
||||
if (cached) return cached;
|
||||
if (url.toLowerCase().includes('.m3u8')) {
|
||||
await saveToCache(key, 'Playlist (size N/A)', subDir);
|
||||
return 'Playlist (size N/A)';
|
||||
}
|
||||
try {
|
||||
const { default: fetch } = await import('node-fetch');
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), 5000);
|
||||
let size = 'Unknown size';
|
||||
try {
|
||||
const head = await fetch(url, { method: 'HEAD', signal: controller.signal });
|
||||
const cl = head.headers.get('content-length');
|
||||
if (cl) {
|
||||
const bytes = parseInt(cl, 10);
|
||||
if (!isNaN(bytes)) {
|
||||
if (bytes < 1024) size = `${bytes} B`;
|
||||
else if (bytes < 1024 * 1024) size = `${(bytes / 1024).toFixed(2)} KB`;
|
||||
else if (bytes < 1024 * 1024 * 1024) size = `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
else size = `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
}
|
||||
} finally { clearTimeout(t); }
|
||||
await saveToCache(key, size, subDir);
|
||||
return size;
|
||||
} catch (_) {
|
||||
await saveToCache(key, 'Unknown size', subDir);
|
||||
return 'Unknown size';
|
||||
}
|
||||
}
|
||||
|
||||
async function getXprimeStreams(title, year, type, seasonNum, episodeNum) {
|
||||
if (!title || !year) return [];
|
||||
const encoded = encodeURIComponent(title);
|
||||
let apiUrl;
|
||||
if (type === 'movie') {
|
||||
apiUrl = `https://backend.xprime.tv/primebox?name=${encoded}&year=${year}&fallback_year=${year}`;
|
||||
} else if (type === 'tv') {
|
||||
if (seasonNum == null || episodeNum == null) return [];
|
||||
apiUrl = `https://backend.xprime.tv/primebox?name=${encoded}&year=${year}&fallback_year=${year}&season=${seasonNum}&episode=${episodeNum}`;
|
||||
} else return [];
|
||||
|
||||
console.log(`[Xprime.tv] Fetch attempt '${title}' (${year}) type=${type}`);
|
||||
|
||||
let resJson;
|
||||
try {
|
||||
const r = await fetchWithRetry(apiUrl, { headers: BROWSER_HEADERS_XPRIME });
|
||||
resJson = await r.json();
|
||||
} catch (e) {
|
||||
console.error('[Xprime.tv] Fetch failed:', e.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
const streams = [];
|
||||
const processItem = (item) => {
|
||||
if (!item || item.error || !item.streams) return;
|
||||
Object.entries(item.streams).forEach(([quality, url]) => {
|
||||
if (!url || typeof url !== 'string') return;
|
||||
streams.push({
|
||||
name: title,
|
||||
title: `${title} - ${type === 'tv' ? `S${String(seasonNum).padStart(2,'0')}E${String(episodeNum).padStart(2,'0')} ` : ''}${quality}`,
|
||||
url,
|
||||
quality: quality || 'Unknown',
|
||||
provider: 'Xprime.tv',
|
||||
headers: {},
|
||||
});
|
||||
});
|
||||
};
|
||||
if (Array.isArray(resJson)) resJson.forEach(processItem); else processItem(resJson);
|
||||
|
||||
if (streams.length) {
|
||||
console.time('[Xprime.tv] Fetch stream sizes');
|
||||
await Promise.all(streams.map(async s => { s.size = await fetchStreamSize(s.url); }));
|
||||
console.timeEnd('[Xprime.tv] Fetch stream sizes');
|
||||
}
|
||||
|
||||
console.log(`[Xprime.tv] Returning ${streams.length} streams.`);
|
||||
return streams;
|
||||
}
|
||||
|
||||
ensureCacheDir(CACHE_DIR).catch(()=>{});
|
||||
|
||||
module.exports = { getXprimeStreams };
|
||||
@@ -0,0 +1,451 @@
|
||||
// Lightweight proxy/m3u8/segment/subtitle rewriting layer. thanks to https://github.com/cinepro-org/ for the original code.
|
||||
// Adapted from external project (user supplied) to fit CommonJS style and existing config system.
|
||||
// Provides conditional route registration via enableProxy flag.
|
||||
|
||||
const cors = require('cors');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
const CACHE_MAX_SIZE = 2000;
|
||||
const CACHE_EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||
const segmentCache = new Map();
|
||||
// Track first open-ended (bytes=0-) range per target to clamp only once per TTL window
|
||||
const openRangeClampMap = new Map();
|
||||
const OPEN_RANGE_CLAMP_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
// Progressive open-ended growth tracking: keeps expanding bytes=0- served window.
|
||||
const progressiveOpenMap = new Map(); // url -> { lastEnd }
|
||||
// Tail prefetch cache: stores last N KB for quick tail range responses
|
||||
const tailPrefetchMap = new Map(); // url -> { data: Buffer, start: number, end: number, size: number, ts: number }
|
||||
const TAIL_PREFETCH_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function isCacheDisabled() {
|
||||
return process.env.DISABLE_CACHE === 'true';
|
||||
}
|
||||
|
||||
function cleanupCache() {
|
||||
const now = Date.now();
|
||||
for (const [url, entry] of segmentCache.entries()) {
|
||||
if (now - entry.timestamp > CACHE_EXPIRY_MS) segmentCache.delete(url);
|
||||
}
|
||||
if (segmentCache.size > CACHE_MAX_SIZE) {
|
||||
const entries = Array.from(segmentCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp);
|
||||
const toRemove = entries.slice(0, segmentCache.size - CACHE_MAX_SIZE);
|
||||
toRemove.forEach(([u]) => segmentCache.delete(u));
|
||||
}
|
||||
return segmentCache.size;
|
||||
}
|
||||
setInterval(cleanupCache, 30 * 60 * 1000).unref();
|
||||
|
||||
function cleanupClampMap() {
|
||||
const now = Date.now();
|
||||
for (const [url, ts] of openRangeClampMap.entries()) {
|
||||
if (now - ts > OPEN_RANGE_CLAMP_TTL_MS) openRangeClampMap.delete(url);
|
||||
}
|
||||
}
|
||||
setInterval(cleanupClampMap, 10 * 60 * 1000).unref();
|
||||
|
||||
function cleanupTailPrefetch() {
|
||||
const now = Date.now();
|
||||
for (const [url, entry] of tailPrefetchMap.entries()) {
|
||||
if (now - entry.ts > TAIL_PREFETCH_TTL_MS) tailPrefetchMap.delete(url);
|
||||
}
|
||||
}
|
||||
setInterval(cleanupTailPrefetch, 15 * 60 * 1000).unref();
|
||||
|
||||
function getCachedSegment(url) {
|
||||
if (isCacheDisabled()) return null;
|
||||
const e = segmentCache.get(url);
|
||||
if (!e) return null;
|
||||
if (Date.now() - e.timestamp > CACHE_EXPIRY_MS) { segmentCache.delete(url); return null; }
|
||||
return e;
|
||||
}
|
||||
|
||||
async function prefetchSegment(url, headers) {
|
||||
if (isCacheDisabled() || segmentCache.size >= CACHE_MAX_SIZE) return;
|
||||
const existing = segmentCache.get(url);
|
||||
if (existing && Date.now() - existing.timestamp <= CACHE_EXPIRY_MS) return;
|
||||
try {
|
||||
const resp = await fetch(url, { headers: { 'User-Agent': DEFAULT_UA, ...headers } });
|
||||
if (!resp.ok) return;
|
||||
const data = new Uint8Array(await resp.arrayBuffer());
|
||||
const responseHeaders = {};
|
||||
resp.headers.forEach((v, k) => responseHeaders[k] = v);
|
||||
segmentCache.set(url, { data, headers: responseHeaders, timestamp: Date.now() });
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
|
||||
const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
|
||||
function inferContentType(upstreamCT, targetUrl) {
|
||||
const bad = !upstreamCT || /application\/octet-stream/i.test(upstreamCT) || /application\/(x-)?zip/i.test(upstreamCT);
|
||||
if (!bad) return upstreamCT;
|
||||
if (/\.mkv(\?|$)/i.test(targetUrl)) return 'video/x-matroska';
|
||||
if (/\.mp4(\?|$)/i.test(targetUrl)) return 'video/mp4';
|
||||
if (/\.m3u8(\?|$)/i.test(targetUrl)) return 'application/vnd.apple.mpegurl';
|
||||
if (/\.ts(\?|$)/i.test(targetUrl)) return 'video/mp2t';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function extractOriginalUrl(proxyUrl) {
|
||||
try {
|
||||
const url = new URL(proxyUrl);
|
||||
if (url.pathname.includes('/proxy/')) {
|
||||
const m = url.pathname.match(/\/proxy\/(.+)$/);
|
||||
if (m) {
|
||||
let decoded = decodeURIComponent(m[1]);
|
||||
while (decoded.includes('%2F')) {
|
||||
try { decoded = decodeURIComponent(decoded); } catch { break; }
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
if (url.searchParams.has('url')) return decodeURIComponent(url.searchParams.get('url'));
|
||||
const patterns = [
|
||||
/\/api\/[^/]+\/proxy\?url=(.+)$/,
|
||||
/\/proxy\?.*url=([^&]+)/,
|
||||
/\/stream\/proxy\/(.+)$/,
|
||||
/\/p\/(.+)$/
|
||||
];
|
||||
for (const p of patterns) {
|
||||
const m = proxyUrl.match(p); if (m) return decodeURIComponent(m[1]);
|
||||
}
|
||||
return proxyUrl;
|
||||
} catch { return proxyUrl; }
|
||||
}
|
||||
|
||||
function rewriteM3u8(content, targetUrl, baseProxyUrl, headers) {
|
||||
const lines = content.split('\n');
|
||||
const out = []; const segmentUrls = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#')) {
|
||||
if (line.startsWith('#EXT-X-KEY:')) {
|
||||
const regex = /https?:\/\/[^""\s]+/g; const keyUrl = regex.exec(line)?.[0];
|
||||
if (keyUrl) {
|
||||
const proxyUrl = `${baseProxyUrl}/ts-proxy?url=${encodeURIComponent(keyUrl)}&headers=${encodeURIComponent(JSON.stringify(headers))}`;
|
||||
out.push(line.replace(keyUrl, proxyUrl));
|
||||
if (!isCacheDisabled()) prefetchSegment(keyUrl, headers);
|
||||
} else out.push(line);
|
||||
} else if (line.startsWith('#EXT-X-MEDIA:') || line.startsWith('#EXT-X-I-FRAME-STREAM-INF:')) {
|
||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||
if (uriMatch) {
|
||||
try {
|
||||
const mediaUrl = new URL(uriMatch[1], targetUrl).href;
|
||||
const proxyUrl = `${baseProxyUrl}/m3u8-proxy?url=${encodeURIComponent(mediaUrl)}&headers=${encodeURIComponent(JSON.stringify(headers))}`;
|
||||
out.push(line.replace(uriMatch[1], proxyUrl));
|
||||
} catch { out.push(line); }
|
||||
} else out.push(line);
|
||||
} else out.push(line);
|
||||
} else if (line.trim()) {
|
||||
try {
|
||||
const abs = new URL(line, targetUrl).href;
|
||||
if (/\.m3u8(\?|$)/i.test(abs)) {
|
||||
out.push(`${baseProxyUrl}/m3u8-proxy?url=${encodeURIComponent(abs)}&headers=${encodeURIComponent(JSON.stringify(headers))}`);
|
||||
} else if (/\.ts(\?|$)/i.test(abs)) {
|
||||
segmentUrls.push(abs);
|
||||
out.push(`${baseProxyUrl}/ts-proxy?url=${encodeURIComponent(abs)}&headers=${encodeURIComponent(JSON.stringify(headers))}`);
|
||||
} else out.push(line);
|
||||
} catch { out.push(line); }
|
||||
} else out.push(line);
|
||||
}
|
||||
if (segmentUrls.length && !isCacheDisabled()) {
|
||||
Promise.all(segmentUrls.map(u => prefetchSegment(u, headers))).catch(() => undefined);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function createProxyRoutes(app) {
|
||||
// m3u8 playlist proxy
|
||||
app.get('/m3u8-proxy', cors(), async (req, res) => {
|
||||
const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'URL parameter required' });
|
||||
let headers = {};
|
||||
try { headers = JSON.parse(req.query.headers || '{}'); } catch {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
try {
|
||||
const response = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers } });
|
||||
if (!response.ok) return res.status(response.status).json({ error: `M3U8 fetch failed: ${response.status}` });
|
||||
const text = await response.text();
|
||||
const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'http';
|
||||
const baseProxyUrl = `${protocol}://${req.get('host')}`;
|
||||
const rewritten = rewriteM3u8(text, targetUrl, baseProxyUrl, headers);
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.send(rewritten);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ts / key segment proxy
|
||||
app.get('/ts-proxy', cors(), async (req, res) => {
|
||||
const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'URL parameter required' });
|
||||
const debug = req.query.debug === '1';
|
||||
const noSynth = req.query.noSynth === '1';
|
||||
const force200 = req.query.force200 === '1';
|
||||
const clampOpen = req.query.clampOpen !== '0';
|
||||
const progressiveOpen = req.query.progressiveOpen !== '0';
|
||||
const tailPrefetchEnabled = req.query.tailPrefetch !== '0';
|
||||
let tailPrefetchKB = parseInt(req.query.tailPrefetchKB || '256', 10);
|
||||
if (isNaN(tailPrefetchKB) || tailPrefetchKB < 64) tailPrefetchKB = 256;
|
||||
if (tailPrefetchKB > 2048) tailPrefetchKB = 2048; // max 2MB tail window
|
||||
let openChunkKB = parseInt(req.query.openChunkKB || '4096', 10);
|
||||
if (isNaN(openChunkKB) || openChunkKB < 64) openChunkKB = 4096;
|
||||
if (openChunkKB > 16384) openChunkKB = 16384; // cap at 16MB
|
||||
let initChunkKB = parseInt(req.query.initChunkKB || '512', 10);
|
||||
if (isNaN(initChunkKB) || initChunkKB < 64) initChunkKB = 512;
|
||||
if (initChunkKB > 2048) initChunkKB = 2048; // hard cap 2MB
|
||||
let headers = {}; try { headers = JSON.parse(req.query.headers || '{}'); } catch {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
// Pass through Range header for progressive playback / seeking
|
||||
const range = req.headers['range'];
|
||||
let appliedClamp = false;
|
||||
let effectiveRange = range;
|
||||
if (range) headers.Range = range;
|
||||
// Clamp first open-ended bytes=0- request to a bounded span (once per TTL) to avoid gigantic initial 206
|
||||
if (range && /^bytes=0-\s*$/i.test(range) && !force200) {
|
||||
const now = Date.now();
|
||||
// Progressive growth supersedes single-shot clamp if enabled
|
||||
if (progressiveOpen) {
|
||||
const prog = progressiveOpenMap.get(targetUrl) || { lastEnd: -1 };
|
||||
const prevEnd = prog.lastEnd;
|
||||
let nextEnd;
|
||||
if (prog.lastEnd < 0) {
|
||||
nextEnd = openChunkKB * 1024 - 1;
|
||||
} else {
|
||||
const increment = openChunkKB * 1024;
|
||||
nextEnd = prog.lastEnd + increment;
|
||||
const maxCap = 256 * 1024 * 1024 - 1; // 256MB
|
||||
if (nextEnd > maxCap) nextEnd = maxCap;
|
||||
}
|
||||
const newRange = `bytes=0-${nextEnd}`;
|
||||
headers.Range = newRange;
|
||||
effectiveRange = newRange;
|
||||
prog.lastEnd = nextEnd;
|
||||
progressiveOpenMap.set(targetUrl, prog);
|
||||
appliedClamp = true; // reuse flag for logging
|
||||
if (debug) console.log('[ts-proxy] progressiveOpen expand', { prevEnd, nextEnd, increment: openChunkKB * 1024 });
|
||||
} else if (clampOpen) {
|
||||
const lastTs = openRangeClampMap.get(targetUrl);
|
||||
if (!lastTs || (now - lastTs) > OPEN_RANGE_CLAMP_TTL_MS) {
|
||||
const clampBytes = openChunkKB * 1024;
|
||||
const newRange = `bytes=0-${clampBytes - 1}`;
|
||||
headers.Range = newRange;
|
||||
effectiveRange = newRange;
|
||||
openRangeClampMap.set(targetUrl, now);
|
||||
appliedClamp = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (debug) console.log('[ts-proxy] incoming', { url: targetUrl, range, effectiveRange, appliedClamp, clampOpen, progressiveOpen, openChunkKB, noSynth, initChunkKB, force200, tailPrefetchEnabled, tailPrefetchKB });
|
||||
if (!isCacheDisabled()) {
|
||||
const cached = getCachedSegment(targetUrl);
|
||||
if (cached) {
|
||||
const ct = inferContentType(cached.headers['content-type'], targetUrl);
|
||||
res.setHeader('Content-Type', ct);
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
if (debug) console.log('[ts-proxy] cache hit');
|
||||
return res.send(Buffer.from(cached.data));
|
||||
}
|
||||
}
|
||||
try {
|
||||
// If we have a tail prefetch entry and the client requests a tail range fully inside it, serve from memory early.
|
||||
if (effectiveRange && /^bytes=\d+-$/i.test(effectiveRange)) {
|
||||
const tailEntry = tailPrefetchMap.get(targetUrl);
|
||||
if (tailEntry) {
|
||||
const m = effectiveRange.match(/bytes=(\d+)-/i);
|
||||
if (m) {
|
||||
const startReq = parseInt(m[1], 10);
|
||||
if (!isNaN(startReq) && startReq >= tailEntry.start && startReq <= tailEntry.end) {
|
||||
const offset = startReq - tailEntry.start;
|
||||
const slice = tailEntry.data.subarray(offset);
|
||||
const endByte = tailEntry.end;
|
||||
res.status(206);
|
||||
const ct = inferContentType(null, targetUrl);
|
||||
res.setHeader('Content-Type', ct);
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Content-Length', slice.length.toString());
|
||||
res.setHeader('Content-Range', `bytes ${startReq}-${endByte}/${tailEntry.size}`);
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (debug) console.log('[ts-proxy] tail serve hit', { startReq, cachedStart: tailEntry.start, cachedEnd: tailEntry.end, size: tailEntry.size });
|
||||
return res.end(slice);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HEAD preflight (only when no explicit Range requested) to get size for synthesized partial response
|
||||
let contentLength = null;
|
||||
let upstreamAcceptRanges = null;
|
||||
if (!effectiveRange) {
|
||||
try {
|
||||
const headResp = await fetch(targetUrl, { method: 'HEAD', headers: { 'User-Agent': DEFAULT_UA, ...headers } });
|
||||
if (headResp.ok) {
|
||||
contentLength = headResp.headers.get('content-length');
|
||||
upstreamAcceptRanges = headResp.headers.get('accept-ranges');
|
||||
if (debug) console.log('[ts-proxy] HEAD ok', { contentLength, upstreamAcceptRanges });
|
||||
} else if (debug) {
|
||||
console.log('[ts-proxy] HEAD status', headResp.status);
|
||||
}
|
||||
} catch (_e) { if (debug) console.log('[ts-proxy] HEAD failed'); }
|
||||
}
|
||||
|
||||
// Fallback probe: if still no size, attempt a 0-0 range request to derive total size from Content-Range
|
||||
if (!effectiveRange && !contentLength) {
|
||||
try {
|
||||
const probeRange = 'bytes=0-0';
|
||||
if (debug) console.log('[ts-proxy] probe range');
|
||||
const probeResp = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers, 'Range': probeRange } });
|
||||
if (probeResp.status === 206) {
|
||||
const cr = probeResp.headers.get('content-range');
|
||||
if (cr) {
|
||||
const m = cr.match(/\/(\d+)$/); if (m) contentLength = m[1];
|
||||
}
|
||||
try { await probeResp.arrayBuffer(); } catch (readErr) {
|
||||
if (debug) console.log('[ts-proxy] probe body read failed', readErr.message);
|
||||
}
|
||||
if (debug) console.log('[ts-proxy] probe success', { contentLength });
|
||||
} else if (debug) {
|
||||
console.log('[ts-proxy] probe status', probeResp.status);
|
||||
}
|
||||
} catch (_e) { if (debug) console.log('[ts-proxy] probe failed'); }
|
||||
}
|
||||
if (debug && contentLength) console.log('[ts-proxy] size determined', contentLength);
|
||||
|
||||
// Initiate tail prefetch asynchronously (once) when size known and feature enabled
|
||||
if (tailPrefetchEnabled && contentLength && !tailPrefetchMap.has(targetUrl)) {
|
||||
const total = parseInt(contentLength, 10);
|
||||
if (!isNaN(total) && total > 0) {
|
||||
const tailBytes = Math.min(tailPrefetchKB * 1024, total);
|
||||
const start = Math.max(0, total - tailBytes);
|
||||
const tailRange = `bytes=${start}-`;
|
||||
(async () => {
|
||||
try {
|
||||
if (debug) console.log('[ts-proxy] tail prefetch start', { tailRange });
|
||||
const tr = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers, Range: tailRange } });
|
||||
if (tr.status === 206) {
|
||||
const buf = Buffer.from(await tr.arrayBuffer());
|
||||
const cr = tr.headers.get('content-range');
|
||||
if (cr) {
|
||||
const mm = cr.match(/bytes\s+(\d+)-(\d+)\/(\d+)/i);
|
||||
if (mm) {
|
||||
const s = parseInt(mm[1], 10); const e = parseInt(mm[2], 10); const sz = parseInt(mm[3], 10);
|
||||
if (!isNaN(s) && !isNaN(e) && !isNaN(sz)) {
|
||||
tailPrefetchMap.set(targetUrl, { data: buf, start: s, end: e, size: sz, ts: Date.now() });
|
||||
if (debug) console.log('[ts-proxy] tail prefetch success', { start: s, end: e, bytes: buf.length });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (debug) {
|
||||
console.log('[ts-proxy] tail prefetch status', tr.status);
|
||||
}
|
||||
} catch (e) { if (debug) console.log('[ts-proxy] tail prefetch error', e.message); }
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
// If no range requested but we now know size (HEAD or probe), synthesize an initial small range fetch to accelerate playback.
|
||||
if (!force200 && !effectiveRange && contentLength && !noSynth && req.query.progressiveOpen === '0') {
|
||||
const total = parseInt(contentLength, 10);
|
||||
const desired = initChunkKB * 1024;
|
||||
const chunkSize = Math.min(desired, Math.max(0, total - 1));
|
||||
const syntheticRange = `bytes=0-${chunkSize}`;
|
||||
const resp = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers, 'Range': syntheticRange } });
|
||||
if (resp.status === 206) {
|
||||
if (debug) console.log('[ts-proxy] synthetic 206', syntheticRange);
|
||||
const upstreamCT = inferContentType(resp.headers.get('content-type'), targetUrl);
|
||||
res.status(206);
|
||||
res.setHeader('Content-Type', upstreamCT);
|
||||
res.setHeader('Accept-Ranges', upstreamAcceptRanges || 'bytes');
|
||||
const contentRange = resp.headers.get('content-range');
|
||||
if (contentRange) res.setHeader('Content-Range', contentRange);
|
||||
const cl = resp.headers.get('content-length'); if (cl) res.setHeader('Content-Length', cl);
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
// Pipe first chunk then leave connection open? Simpler: end after chunk; player will request next range.
|
||||
return resp.body.pipe(res);
|
||||
}
|
||||
// If server ignored range (e.g., returned 200), fall through to normal logic below.
|
||||
}
|
||||
|
||||
const upstreamOptions = { headers: { 'User-Agent': DEFAULT_UA, ...headers } };
|
||||
// If client supplied a suffix range like bytes=0- we pass it; force200 strips Range
|
||||
if (force200 && headers.Range) delete headers.Range;
|
||||
const resp = await fetch(targetUrl, upstreamOptions);
|
||||
if (!resp.ok && resp.status !== 206) {
|
||||
if (debug) console.log('[ts-proxy] upstream error', resp.status);
|
||||
return res.status(resp.status).json({ error: `TS fetch failed: ${resp.status}` });
|
||||
}
|
||||
// Forward partial content status when range used
|
||||
if (effectiveRange && resp.status === 206 && !force200) { res.status(206); if (debug) console.log('[ts-proxy] forwarding 206', { appliedClamp }); }
|
||||
if (force200 && resp.status === 206) {
|
||||
// Some origins still return 206 even without Range; normalize to 200 for players expecting full stream
|
||||
if (debug) console.log('[ts-proxy] normalizing 206 -> 200 due to force200');
|
||||
}
|
||||
// Copy key streaming headers
|
||||
const upstreamCT = inferContentType(resp.headers.get('content-type'), targetUrl);
|
||||
res.setHeader('Content-Type', upstreamCT);
|
||||
let cl = resp.headers.get('content-length');
|
||||
const crHeader = resp.headers.get('content-range');
|
||||
if (!cl && crHeader) {
|
||||
// Try to derive length from range span
|
||||
const m = crHeader.match(/bytes\s+(\d+)-(\d+)\/(\d+|\*)/i);
|
||||
if (m) {
|
||||
const start = parseInt(m[1], 10); const end = parseInt(m[2], 10);
|
||||
if (!isNaN(start) && !isNaN(end)) cl = (end - start + 1).toString();
|
||||
}
|
||||
}
|
||||
if (cl) res.setHeader('Content-Length', cl);
|
||||
const acceptRanges = resp.headers.get('accept-ranges');
|
||||
if (acceptRanges) res.setHeader('Accept-Ranges', acceptRanges); else res.setHeader('Accept-Ranges', 'bytes');
|
||||
const contentRange = resp.headers.get('content-range'); if (contentRange && !force200) res.setHeader('Content-Range', contentRange);
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (debug) console.log('[ts-proxy] streaming body', { status: resp.status, ct: upstreamCT, cl, contentRange: force200 ? undefined : contentRange });
|
||||
resp.body.pipe(res);
|
||||
} catch (e) { if (debug) console.log('[ts-proxy] exception', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// subtitle proxy
|
||||
app.get('/sub-proxy', cors(), async (req, res) => {
|
||||
const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'url parameter required' });
|
||||
let headers = {}; try { headers = JSON.parse(req.query.headers || '{}'); } catch {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers } });
|
||||
if (!resp.ok) return res.status(resp.status).json({ error: `subtitle fetch failed: ${resp.status}` });
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('Content-Type', resp.headers.get('content-type') || 'text/vtt');
|
||||
resp.body.pipe(res);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
}
|
||||
|
||||
function processStreamsForProxy(streams, serverUrl) {
|
||||
if (!Array.isArray(streams)) return streams;
|
||||
return streams.map(s => {
|
||||
if (!s || !s.url || typeof s.url !== 'string') return s;
|
||||
const original = extractOriginalUrl(s.url);
|
||||
const headers = s.headers || {};
|
||||
const hParam = Object.keys(headers).length ? `&headers=${encodeURIComponent(JSON.stringify(headers))}` : '';
|
||||
let host = '';
|
||||
try {
|
||||
host = new URL(original).hostname.toLowerCase();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
// Force specific hosts through ts-proxy (direct file style) even without extension
|
||||
if (host.includes('pixeldrain.') || host === 'video-downloads.googleusercontent.com') {
|
||||
return { ...s, url: `${serverUrl}/ts-proxy?url=${encodeURIComponent(original)}${hParam}` };
|
||||
}
|
||||
if (/\.(mp4|mkv)(\?|$)/i.test(original)) {
|
||||
return { ...s, url: `${serverUrl}/ts-proxy?url=${encodeURIComponent(original)}${hParam}` };
|
||||
}
|
||||
return { ...s, url: `${serverUrl}/m3u8-proxy?url=${encodeURIComponent(original)}${hParam}` };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createProxyRoutes, processStreamsForProxy };
|
||||
+1
-1
@@ -177,12 +177,12 @@
|
||||
<label class="adv-flag-item"><input type="checkbox" name="adv_disableCache" value="1" /> <span>Disable Cache</span></label>
|
||||
<label class="adv-flag-item"><input type="checkbox" name="adv_disableUrlValidation" value="1" /> <span>Disable URL Validation</span></label>
|
||||
<label class="adv-flag-item"><input type="checkbox" name="adv_disable4khdhubValidation" value="1" /> <span>Disable 4KHDHub Validation</span></label>
|
||||
<label class="adv-flag-item"><input type="checkbox" name="adv_enableProxy" value="1" /> <span>Enable Stream Proxy</span></label>
|
||||
</div>
|
||||
<h1>Paths</h1>
|
||||
<p class="hint">Provider enable/disable & feature flags apply immediately. Click toggles to enable (highlighted) or disable (dim).</p>
|
||||
<div class="adv-group">
|
||||
<div class="field small"><label>Showbox Cache Dir <input type="text" name="adv_showboxCacheDir" placeholder="/data/showbox-cache"/></label></div>
|
||||
<div class="field small"><label>MoviesClub Proxy URL <p class="hint">Optional. Example: <code>https://winter-surf-e49f.inside4ndroid-techsup.workers.dev/?destination=</code><br/>Leave blank for direct links. The raw stream URL will be appended (URL encoded).</p><input type="text" name="adv_moviesclubProxy" placeholder="https://proxy/?destination=" /></label></div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="panel-live" class="panel">
|
||||
|
||||
+3
-5
@@ -160,12 +160,12 @@ function fillForm(data){
|
||||
tmdbBanner.style.display = (tmdbMissing && tmdbProvidersEnabled)? 'block':'none';
|
||||
}
|
||||
const advBools = [
|
||||
['enableXprimeProvider','adv_enableXprime'],
|
||||
['enable4khdhubProvider','adv_enable4khdhub'],
|
||||
['enableMoviesmodProvider','adv_enableMoviesmod'],
|
||||
['enableMp4hydraProvider','adv_enableMp4hydra'],
|
||||
['enableVidzeeProvider','adv_enableVidzee'],
|
||||
['enableVixsrcProvider','adv_enableVixsrc'],
|
||||
['enableProxy','adv_enableProxy'],
|
||||
['disableCache','adv_disableCache'],
|
||||
['enablePStreamApi','adv_enablePStream'],
|
||||
['disableUrlValidation','adv_disableUrlValidation'],
|
||||
@@ -174,7 +174,6 @@ function fillForm(data){
|
||||
advBools.forEach(([cfgKey, elName])=>{ const el = f.elements[elName]; if(!el) return; const src = (override[cfgKey]!==undefined? override[cfgKey] : merged[cfgKey]); if(el.type==='checkbox'){ el.checked = !!src; } else { el.value = src? 'true':''; } });
|
||||
const advTexts = [
|
||||
['showboxCacheDir','adv_showboxCacheDir']
|
||||
,['moviesclubProxy','adv_moviesclubProxy']
|
||||
];
|
||||
advTexts.forEach(([cfgKey, elName])=>{ const el = f.elements[elName]; if(!el) return; const val = override[cfgKey]!==undefined? override[cfgKey] : merged[cfgKey]; el.value = val || ''; });
|
||||
// Sync checkbox style advanced flags
|
||||
@@ -245,12 +244,12 @@ async function save(){
|
||||
payload.febboxCookies = cookiesVal? cookiesVal.split(',').filter(Boolean) : [];
|
||||
}
|
||||
const boolMap = {
|
||||
adv_enableXprime:'enableXprimeProvider',
|
||||
adv_enable4khdhub:'enable4khdhubProvider',
|
||||
adv_enableMoviesmod:'enableMoviesmodProvider',
|
||||
adv_enableMp4hydra:'enableMp4hydraProvider',
|
||||
adv_enableVidzee:'enableVidzeeProvider',
|
||||
adv_enableVixsrc:'enableVixsrcProvider',
|
||||
adv_enableProxy:'enableProxy',
|
||||
adv_disableCache:'disableCache',
|
||||
adv_enablePStream:'enablePStreamApi',
|
||||
adv_disableUrlValidation:'disableUrlValidation',
|
||||
@@ -258,7 +257,6 @@ async function save(){
|
||||
};
|
||||
Object.entries(boolMap).forEach(([formName,cfgKey])=>{ const el = f.elements[formName]; if(el) payload[cfgKey] = !!el.checked; });
|
||||
const textMap = { adv_showboxCacheDir:'showboxCacheDir' };
|
||||
textMap.adv_moviesclubProxy = 'moviesclubProxy';
|
||||
Object.entries(textMap).forEach(([formName,cfgKey])=>{ const el = f.elements[formName]; if(!el) return; const v = el.value.trim(); payload[cfgKey] = v? v : null; });
|
||||
const tmdbHidden = f.elements['tmdbApiKeysHidden'];
|
||||
if (tmdbHidden){
|
||||
@@ -339,12 +337,12 @@ async function clearAll(){
|
||||
tmdbApiKey: null,
|
||||
// Provider enable flags default to true
|
||||
enableShowboxProvider: true,
|
||||
enableXprimeProvider: true,
|
||||
enable4khdhubProvider: true,
|
||||
enableMoviesmodProvider: true,
|
||||
enableMp4hydraProvider: true,
|
||||
enableVidzeeProvider: true,
|
||||
enableVixsrcProvider: true,
|
||||
enableProxy: false,
|
||||
// Other defaults
|
||||
disableCache: false,
|
||||
enablePStreamApi: true,
|
||||
|
||||
+3
-9
@@ -97,7 +97,7 @@ function normalizeConfig(base) {
|
||||
// Dynamic provider enable flags
|
||||
const providerNames = getProviderNames();
|
||||
const boolKeys = providerNames.map(name => `enable${name.charAt(0).toUpperCase() + name.slice(1)}Provider`);
|
||||
boolKeys.push('disableCache', 'enablePStreamApi', 'disableUrlValidation', 'disable4khdhubUrlValidation');
|
||||
boolKeys.push('disableCache', 'enablePStreamApi', 'disableUrlValidation', 'disable4khdhubUrlValidation', 'enableProxy');
|
||||
|
||||
boolKeys.forEach(k=>{ if (cfg[k] === 'true') cfg[k] = true; else if (cfg[k] === 'false') cfg[k] = false; });
|
||||
|
||||
@@ -110,14 +110,10 @@ function normalizeConfig(base) {
|
||||
// Default values for other flags
|
||||
if (cfg.disableCache === undefined) cfg.disableCache = false;
|
||||
if (cfg.enablePStreamApi === undefined) cfg.enablePStreamApi = true;
|
||||
if (cfg.enableProxy === undefined) cfg.enableProxy = false; // default off
|
||||
// Proxy features removed; always use direct connections
|
||||
if (cfg.disableUrlValidation === undefined) cfg.disableUrlValidation = false;
|
||||
if (cfg.disable4khdhubUrlValidation === undefined) cfg.disable4khdhubUrlValidation = false;
|
||||
// MoviesClub optional proxy base (string URL with trailing ?destination= or similar)
|
||||
if (cfg.moviesclubProxy !== undefined && typeof cfg.moviesclubProxy === 'string') {
|
||||
cfg.moviesclubProxy = cfg.moviesclubProxy.trim();
|
||||
if (!cfg.moviesclubProxy) delete cfg.moviesclubProxy;
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -147,18 +143,16 @@ function applyConfigToEnv(cfg){
|
||||
process.env.ENABLE_PSTREAM_API = cfg.enablePStreamApi ? 'true':'false';
|
||||
process.env.DISABLE_URL_VALIDATION = cfg.disableUrlValidation ? 'true':'false';
|
||||
process.env.DISABLE_4KHDHUB_URL_VALIDATION = cfg.disable4khdhubUrlValidation ? 'true':'false';
|
||||
process.env.ENABLE_PROXY = cfg.enableProxy ? 'true':'false';
|
||||
// Showbox specific
|
||||
if (cfg.showboxCacheDir) process.env.SHOWBOX_CACHE_DIR = cfg.showboxCacheDir; else delete process.env.SHOWBOX_CACHE_DIR;
|
||||
// Proxy settings removed; ensure legacy envs are cleared
|
||||
delete process.env.SHOWBOX_USE_ROTATING_PROXY;
|
||||
delete process.env.SHOWBOX_PROXY_URL_VALUE;
|
||||
delete process.env.SHOWBOX_PROXY_URL_ALTERNATE;
|
||||
delete process.env.XPRIME_PROXY_URL;
|
||||
delete process.env.VIDZEE_PROXY_URL;
|
||||
delete process.env.VIDSRC_PROXY_URL;
|
||||
delete process.env.MOVIESMOD_PROXY_URL;
|
||||
// Mirror moviesclub proxy for potential legacy usage
|
||||
if (cfg.moviesclubProxy) process.env.MOVIESCLUB_PROXY_URL = cfg.moviesclubProxy; else delete process.env.MOVIESCLUB_PROXY_URL;
|
||||
if (cfg.defaultRegion) process.env.FEBBOX_REGION = cfg.defaultRegion; // alias
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -67,10 +67,14 @@ class JsUnpacker {
|
||||
let count = 0;
|
||||
try {
|
||||
radix = parseInt(radixStr);
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
try {
|
||||
count = parseInt(countStr);
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
|
||||
if (symtab.length !== count) {
|
||||
throw new Error('Unknown p.a.c.k.e.r. encoding');
|
||||
|
||||
+5
-10
@@ -1,24 +1,19 @@
|
||||
{
|
||||
"defaultProviders": [],
|
||||
"configVersion": 1,
|
||||
"tmdbApiKeys": [
|
||||
"f3c627493095a7e40ceca68355c94c6d"
|
||||
],
|
||||
"tmdbApiKeys": [],
|
||||
"enableShowboxProvider": true,
|
||||
"febboxCookies": [
|
||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NTc5NjYxMDAsIm5iZiI6MTc1Nzk2NjEwMCwiZXhwIjoxNzg5MDcwMTIwLCJkYXRhIjp7InVpZCI6ODc2NTEwLCJ0b2tlbiI6ImY0MDQxNWJkMTdiYjc1YmQ2ZWQ1NzQ1ZTFhZmNlOTM5In19.vs_pLXAJYhnEaHDfcR_dv9-3us2MJhMOMHUC4la9Rag"
|
||||
],
|
||||
"enableXprimeProvider": true,
|
||||
"febboxCookies": [],
|
||||
"enable4khdhubProvider": true,
|
||||
"enableMoviesmodProvider": true,
|
||||
"enableMp4hydraProvider": true,
|
||||
"enableVidzeeProvider": true,
|
||||
"enableVixsrcProvider": true,
|
||||
"disableCache": true,
|
||||
"enableProxy": false,
|
||||
"disableCache": false,
|
||||
"enablePStreamApi": true,
|
||||
"disableUrlValidation": false,
|
||||
"disable4khdhubUrlValidation": false,
|
||||
"excludeCodecsRaw": "{\"excludeDV\":false,\"excludeHDR\":false}",
|
||||
"minQualitiesRaw": "all",
|
||||
"port": 8787
|
||||
"minQualitiesRaw": "all"
|
||||
}
|
||||
Reference in New Issue
Block a user