diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7d191..f0efb16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 21d5467..f88b63b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - +

diff --git a/apiServer.js b/apiServer.js index cfc70e8..61e849d 100644 --- a/apiServer.js +++ b/apiServer.js @@ -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; diff --git a/package-lock.json b/package-lock.json index 8d62993..d6531aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 36c522d..2ec6e66 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/providers/4khdhub.js b/providers/4khdhub.js index c711bf1..58c2aa8 100644 --- a/providers/4khdhub.js +++ b/providers/4khdhub.js @@ -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; diff --git a/providers/Showbox.js b/providers/Showbox.js index 7fa56f5..5a024c9 100644 --- a/providers/Showbox.js +++ b/providers/Showbox.js @@ -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 }; diff --git a/providers/VidZee.js b/providers/VidZee.js index 314110f..2d23524 100644 --- a/providers/VidZee.js +++ b/providers/VidZee.js @@ -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: { diff --git a/providers/moviesclub.js b/providers/moviesclub.js deleted file mode 100644 index ce36a19..0000000 --- a/providers/moviesclub.js +++ /dev/null @@ -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(/
]*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(/]*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(/]*>[\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 -}; \ No newline at end of file diff --git a/providers/registry.js b/providers/registry.js index d03c9a0..f6e0804 100644 --- a/providers/registry.js +++ b/providers/registry.js @@ -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); diff --git a/providers/vixsrc.js b/providers/vixsrc.js index 571c136..d7435ca 100644 --- a/providers/vixsrc.js +++ b/providers/vixsrc.js @@ -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>/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>/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 }; diff --git a/providers/xprime.js b/providers/xprime.js deleted file mode 100644 index 466c12c..0000000 --- a/providers/xprime.js +++ /dev/null @@ -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 }; \ No newline at end of file diff --git a/proxy/proxyServer.js b/proxy/proxyServer.js new file mode 100644 index 0000000..ff56de4 --- /dev/null +++ b/proxy/proxyServer.js @@ -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 }; diff --git a/public/config.html b/public/config.html index 8a88655..7a28612 100644 --- a/public/config.html +++ b/public/config.html @@ -177,12 +177,12 @@ +

Paths

Provider enable/disable & feature flags apply immediately. Click toggles to enable (highlighted) or disable (dim).

-
diff --git a/public/index.js b/public/index.js index 927f45e..7e7d536 100644 --- a/public/index.js +++ b/public/index.js @@ -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, diff --git a/utils/config.js b/utils/config.js index cad7a50..7ae74d0 100644 --- a/utils/config.js +++ b/utils/config.js @@ -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 } diff --git a/utils/jsunpack.js b/utils/jsunpack.js index a99d572..588ea50 100644 --- a/utils/jsunpack.js +++ b/utils/jsunpack.js @@ -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'); diff --git a/utils/user-config.json b/utils/user-config.json index e107beb..a9c3692 100644 --- a/utils/user-config.json +++ b/utils/user-config.json @@ -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" } \ No newline at end of file