mirror of
https://github.com/Inside4ndroid/TMDB-Embed-API.git
synced 2026-10-22 09:35:11 +00:00
3095 lines
144 KiB
JavaScript
3095 lines
144 KiB
JavaScript
require('dotenv').config();
|
|
console.log(`Current DISABLE_CACHE value: '${process.env.DISABLE_CACHE}' (Type: ${typeof process.env.DISABLE_CACHE})`);
|
|
const axios = require('axios');
|
|
const cheerio = require('cheerio');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
// Redis removed: all caching now uses filesystem only.
|
|
|
|
// --- Cookie Management --- (removed unused cookieIndex/detectedOssGroup)
|
|
|
|
|
|
// Add configurable default region
|
|
const DEFAULT_OSS_REGION = process.env.FEBBOX_REGION || 'USA7';
|
|
console.log(`Using FebBox region setting: ${DEFAULT_OSS_REGION}`);
|
|
|
|
// Global variable to store the user's region preference from the request
|
|
global.currentRequestRegionPreference = null;
|
|
|
|
// Add a global variable to track region availability
|
|
global.regionAvailabilityStatus = {};
|
|
const US_FALLBACK_REGIONS = ['USA7', 'USA6', 'USA5']; // Prioritized US regions for fallback
|
|
|
|
// Modify the getCookieForRequest function to handle region fallbacks
|
|
// Quota-aware cookie selection: if global.currentRequestConfig.cookies exists (array),
|
|
// select the best cookie by querying flow from FebBox and choosing the highest remaining.
|
|
// Fallback to existing single-cookie/rotation behavior.
|
|
const getCookieForRequest = async (regionPreference = null, userCookie = null) => {
|
|
let detectedOssGroup = null;
|
|
const originalRegion = regionPreference;
|
|
let usingFallback = false;
|
|
let baseCookieToUse = null;
|
|
|
|
// Region detection
|
|
if (regionPreference) {
|
|
if (global.regionAvailabilityStatus && global.regionAvailabilityStatus[regionPreference] === false) {
|
|
usingFallback = true;
|
|
for (const fallback of US_FALLBACK_REGIONS) {
|
|
if (!global.regionAvailabilityStatus || global.regionAvailabilityStatus[fallback] !== false) {
|
|
detectedOssGroup = fallback;
|
|
console.log(`[CookieManager] Region ${regionPreference} unavailable, fallback to ${detectedOssGroup}`);
|
|
break;
|
|
}
|
|
}
|
|
if (!detectedOssGroup) {
|
|
detectedOssGroup = regionPreference;
|
|
console.log(`[CookieManager] All fallbacks unavailable, trying original ${detectedOssGroup}`);
|
|
}
|
|
} else {
|
|
detectedOssGroup = regionPreference;
|
|
console.log(`[CookieManager] Using explicit region: ${detectedOssGroup}`);
|
|
}
|
|
} else {
|
|
detectedOssGroup = DEFAULT_OSS_REGION;
|
|
console.log(`[CookieManager] No region preference; defaulting to ${detectedOssGroup}`);
|
|
}
|
|
|
|
global.lastRequestedRegion = { original: originalRegion, used: detectedOssGroup, usingFallback };
|
|
|
|
// Select cookie (simplified legacy logic retained)
|
|
if (!baseCookieToUse && global.currentRequestConfig) {
|
|
try {
|
|
const array = Array.isArray(global.currentRequestConfig.cookies) ? global.currentRequestConfig.cookies.filter(Boolean) : [];
|
|
if (array.length > 0) {
|
|
// Just pick first for now (quota logic removed here if previously elsewhere)
|
|
baseCookieToUse = array[0];
|
|
}
|
|
} catch (e) {
|
|
console.warn(`[CookieManager] Cookie array selection error: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// Check if a base cookie has already been chosen and cached for this specific request cycle
|
|
if (global.currentRequestConfig && global.currentRequestConfig.chosenFebboxBaseCookieForRequest) {
|
|
console.log(`[CookieManager] Re-using request-cached base cookie for this cycle.`);
|
|
baseCookieToUse = global.currentRequestConfig.chosenFebboxBaseCookieForRequest;
|
|
} else if (!baseCookieToUse) {
|
|
// No request-cached cookie, so we need to select one.
|
|
// 1. Prioritize user-supplied cookie passed directly to this function
|
|
if (userCookie) {
|
|
console.log('[CookieManager] Using user-supplied cookie passed to function for this cycle.');
|
|
baseCookieToUse = userCookie;
|
|
}
|
|
// 2. Prioritize user-supplied cookie from global (fallback for backward compatibility)
|
|
else if (global.currentRequestUserCookie) {
|
|
console.log('[CookieManager] Using user-supplied cookie from global state (legacy mode) for this cycle.');
|
|
baseCookieToUse = global.currentRequestUserCookie;
|
|
}
|
|
// No file-based fallback rotation (env-only)
|
|
else {
|
|
console.log('[CookieManager] No user-supplied cookies available for this cycle.');
|
|
baseCookieToUse = '';
|
|
}
|
|
|
|
// Cache the chosen base cookie for this request cycle
|
|
if (global.currentRequestConfig) {
|
|
global.currentRequestConfig.chosenFebboxBaseCookieForRequest = baseCookieToUse;
|
|
console.log(`[CookieManager] Cached base cookie for this request cycle.`);
|
|
}
|
|
}
|
|
|
|
// Now, apply the detected OSS group to the chosen base cookie
|
|
if (baseCookieToUse && baseCookieToUse.trim() !== '') {
|
|
if (detectedOssGroup) {
|
|
console.log(`[CookieManager] Applying region: ${detectedOssGroup} to chosen base cookie.`);
|
|
return `${baseCookieToUse}; oss_group=${detectedOssGroup}`;
|
|
}
|
|
return baseCookieToUse; // Return base cookie if no region detected/needed
|
|
} else {
|
|
if (detectedOssGroup) return `oss_group=${detectedOssGroup}`;
|
|
return '';
|
|
}
|
|
};
|
|
|
|
// Helper function to fetch stream size using a HEAD request
|
|
const fetchStreamSize = async (url) => {
|
|
const cacheSubDir = 'stream_sizes';
|
|
// Create a cache key from the URL using hash to avoid long filenames
|
|
const urlHash = crypto.createHash('md5').update(url).digest('hex');
|
|
const urlCacheKey = `${urlHash}.txt`;
|
|
|
|
const cachedSize = await getFromCache(urlCacheKey, cacheSubDir);
|
|
if (cachedSize !== null) { // Check for null specifically, as 'Unknown size' is a valid cached string
|
|
// console.log(` CACHE HIT for stream size: ${url} -> ${cachedSize}`);
|
|
return cachedSize;
|
|
}
|
|
// console.log(` CACHE MISS for stream size: ${url}`);
|
|
|
|
try {
|
|
// For m3u8, Content-Length is for the playlist file, not the stream segments.
|
|
if (url.toLowerCase().includes('.m3u8')) {
|
|
return 'Playlist (size N/A)'; // Indicate HLS playlist
|
|
}
|
|
const response = await axios.head(url, { timeout: 5000 }); // 5-second timeout for HEAD request
|
|
if (response.headers['content-length']) {
|
|
const sizeInBytes = parseInt(response.headers['content-length'], 10);
|
|
if (!isNaN(sizeInBytes)) {
|
|
if (sizeInBytes < 1024) return `${sizeInBytes} B`;
|
|
if (sizeInBytes < 1024 * 1024) return `${(sizeInBytes / 1024).toFixed(2)} KB`;
|
|
if (sizeInBytes < 1024 * 1024 * 1024) return `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
return `${(sizeInBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
}
|
|
}
|
|
return 'Unknown size';
|
|
} catch (error) {
|
|
// console.warn(` Could not fetch size for ${url}: ${error.message}`);
|
|
// Cache the error/unknown result too, to prevent re-fetching a known problematic URL quickly
|
|
await saveToCache(urlCacheKey, 'Unknown size', cacheSubDir);
|
|
return 'Unknown size';
|
|
}
|
|
};
|
|
|
|
const FEBBOX_PLAYER_URL = "https://www.febbox.com/file/player";
|
|
const FEBBOX_FILE_SHARE_LIST_URL = "https://www.febbox.com/file/file_share_list";
|
|
// MODIFICATION: Remove SCRAPER_API_URL
|
|
// const SCRAPER_API_URL = 'https://api.scraperapi.com/';
|
|
// TMDB key now sourced from config/env; no hardcoded public key
|
|
const { getTmdbApiKey } = require('../utils/tmdbKey');
|
|
function obtainTmdbKey() {
|
|
const k = getTmdbApiKey();
|
|
if (!k) console.warn('[Showbox] No TMDB API key available (multi-key set empty).');
|
|
return k;
|
|
}
|
|
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
|
|
// Determine cache directory based on environment
|
|
// Use /tmp/.cache when running on Vercel, otherwise use local .cache directory
|
|
// Allow external override to keep cache writes out of watched tree during dev
|
|
const CACHE_DIR = process.env.SHOWBOX_CACHE_DIR
|
|
? process.env.SHOWBOX_CACHE_DIR
|
|
: (process.env.VERCEL ? path.join('/tmp', '.cache') : path.join(require('os').tmpdir(), 'tmdb-embed-showbox-cache'));
|
|
console.log(`Using cache directory: ${CACHE_DIR}`);
|
|
|
|
|
|
// Ensure cache directories exist
|
|
const ensureCacheDir = async (dirPath) => {
|
|
try {
|
|
await fs.mkdir(dirPath, { recursive: true });
|
|
} catch (error) {
|
|
if (error.code !== 'EEXIST') {
|
|
console.warn(`Warning: Could not create cache directory ${dirPath}: ${error.message}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Cache helpers
|
|
const getFromCache = async (cacheKey, subDir = '') => {
|
|
if (process.env.DISABLE_CACHE === 'true') {
|
|
console.log(` CACHE DISABLED: Skipping read for ${path.join(subDir, cacheKey)}`);
|
|
return null;
|
|
}
|
|
|
|
// File system cache only (Redis removed)
|
|
const cachePath = path.join(CACHE_DIR, subDir, cacheKey);
|
|
try {
|
|
const fileData = await fs.readFile(cachePath, 'utf-8');
|
|
console.log(` FILE SYSTEM CACHE HIT for: ${path.join(subDir, cacheKey)}`);
|
|
// Redis removed: no cross-population
|
|
try {
|
|
return JSON.parse(fileData);
|
|
} catch (e) {
|
|
return fileData; // Return as string if not JSON
|
|
}
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') {
|
|
console.warn(` FILE SYSTEM CACHE READ ERROR for ${cacheKey}: ${error.message}`);
|
|
} else {
|
|
console.log(` FILE SYSTEM CACHE MISS for: ${path.join(subDir, cacheKey)}`);
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const saveToCache = async (cacheKey, content, subDir = '') => {
|
|
if (process.env.DISABLE_CACHE === 'true') {
|
|
console.log(` CACHE DISABLED: Skipping write for ${path.join(subDir, cacheKey)}`);
|
|
return;
|
|
}
|
|
|
|
const dataToSave = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
|
// Save to file system cache
|
|
const fullSubDir = path.join(CACHE_DIR, subDir);
|
|
await ensureCacheDir(fullSubDir);
|
|
const cachePath = path.join(fullSubDir, cacheKey);
|
|
try {
|
|
await fs.writeFile(cachePath, dataToSave, 'utf-8');
|
|
console.log(` SAVED TO FILE SYSTEM CACHE: ${path.join(subDir, cacheKey)}`);
|
|
} catch (error) {
|
|
console.warn(` FILE SYSTEM CACHE WRITE ERROR for ${cacheKey}: ${error.message}`);
|
|
}
|
|
};
|
|
|
|
// NEW HELPER FUNCTIONS
|
|
|
|
// Function to create URL-friendly slugs
|
|
const slugify = (text) => {
|
|
if (!text) return '';
|
|
return text
|
|
.toString()
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/&/g, 'and') // Replace & with 'and'
|
|
.replace(/[_\s]+/g, '-') // Replace spaces and underscores with -
|
|
.replace(/[^\w-]+/g, '') // Remove all non-word chars (except -)
|
|
.replace(/--+/g, '-') // Replace multiple - with single -
|
|
.replace(/^-+/, '') // Trim - from start of text
|
|
.replace(/-+$/, ''); // Trim - from end of text
|
|
};
|
|
|
|
// Function to normalize titles for comparison
|
|
const normalizeTitleForComparison = (title) => {
|
|
if (!title) return '';
|
|
// Remove common season/episode patterns, year in parenthesis, and normalize
|
|
return title
|
|
.toLowerCase()
|
|
.replace(/\(?\d{4}\)?$/, '') // Remove year like (2023) at the end
|
|
.replace(/\s*-\s*(season|s)\s*\d+\s*(episode|e)\s*\d+/i, '') // Remove SXXEXX patterns
|
|
.replace(/\s*(season|s)\s*\d+/i, '') // Remove SXX patterns
|
|
.replace(/\s*(episode|e)\s*\d+/i, '') // Remove EXX patterns
|
|
.replace(/[^\w\s]|_/g, "") // Remove punctuation except spaces
|
|
.replace(/\s+/g, ' ') // Normalize multiple spaces to one
|
|
.trim();
|
|
};
|
|
|
|
// Function to validate if a ShowBox page title matches TMDB titles
|
|
const validateShowboxTitle = (showboxPageTitle, tmdbMainTitle, tmdbOriginalTitle, tmdbAlternativeTitles = []) => {
|
|
if (!showboxPageTitle || !tmdbMainTitle) {
|
|
console.log(` [Validation] Missing titles. SB: "${showboxPageTitle}", TMDB Main: "${tmdbMainTitle}"`);
|
|
return false;
|
|
}
|
|
|
|
const normalizedPageTitle = normalizeTitleForComparison(showboxPageTitle);
|
|
|
|
// Collect all TMDB titles for comparison (main, original, alternatives)
|
|
const titlesToCompare = [tmdbMainTitle, tmdbOriginalTitle, ...tmdbAlternativeTitles.map(alt => alt.title)]
|
|
.filter(Boolean) // Remove any null/undefined titles
|
|
.map(normalizeTitleForComparison); // Normalize all titles
|
|
|
|
// Check for exact matches
|
|
if (titlesToCompare.some(normTmdbTitle => normalizedPageTitle === normTmdbTitle && normTmdbTitle.length > 0)) {
|
|
console.log(` [Validation] SUCCESS: Exact match. SB: "${normalizedPageTitle}" == TMDB: One of "${titlesToCompare.filter(t => t.length > 0).join('", "')}"`);
|
|
return true;
|
|
}
|
|
|
|
// Check for partial matches (e.g., "Title" in "Title: The Series" or vice versa)
|
|
if (titlesToCompare.some(normTmdbTitle =>
|
|
normTmdbTitle.length > 3 && normalizedPageTitle.length > 3 && // ensure titles aren't too short
|
|
(normalizedPageTitle.includes(normTmdbTitle) || normTmdbTitle.includes(normalizedPageTitle))
|
|
)) {
|
|
console.log(` [Validation] SUCCESS: Partial match. SB: "${normalizedPageTitle}" vs TMDB: One of "${titlesToCompare.filter(t => t.length > 0).join('", "')}"`);
|
|
return true;
|
|
}
|
|
|
|
console.log(` [Validation] FAILED: No strong match. SB: "${normalizedPageTitle}" vs TMDB Titles: "${titlesToCompare.filter(t => t.length > 0).join('", "')}"`);
|
|
return false;
|
|
};
|
|
|
|
// NEW HELPER FUNCTION: Extract TMDB image path from ShowBox detail page HTML
|
|
const extractTmdbImagePathFromShowboxHtml = (htmlContent) => {
|
|
if (!htmlContent) return null;
|
|
// Log the beginning of the HTML content for debugging
|
|
console.log(` [ImageExtract DEBUG] HTML content (first 500 chars): ${String(htmlContent).substring(0, 500)}`);
|
|
try {
|
|
const $ = cheerio.load(htmlContent);
|
|
const coverDiv = $('div.cover_follow');
|
|
if (coverDiv.length) {
|
|
const styleAttr = coverDiv.attr('style');
|
|
console.log(` [ImageExtract DEBUG] Found div.cover_follow. Style attribute: ${styleAttr}`); // Log the style attribute
|
|
if (styleAttr) {
|
|
// MODIFIED REGEX: Made " optional
|
|
const urlMatch = styleAttr.match(/url\((?:")?(https?:\/\/image\.tmdb\.org\/t\/p\/original)?([^&]+?)(?:")?\)/);
|
|
if (urlMatch && urlMatch[2]) {
|
|
// Ensure it's a path, not a full URL if accidentally captured
|
|
const imagePath = urlMatch[2].startsWith('/') ? urlMatch[2] : '/' + urlMatch[2];
|
|
console.log(` [ImageExtract] Extracted TMDB image path from ShowBox HTML: ${imagePath}`);
|
|
return imagePath;
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(` [ImageExtract] Error parsing ShowBox HTML for TMDB image: ${e.message}`);
|
|
}
|
|
console.log(` [ImageExtract] Could not find TMDB image path in ShowBox HTML.`);
|
|
return null;
|
|
};
|
|
|
|
// NEW HELPER FUNCTION: Validate ShowBox TMDB image path against TMDB API backdrop paths
|
|
const validateTmdbImage = (showboxImagePath, tmdbApiBackdropPaths = []) => {
|
|
if (!showboxImagePath || !tmdbApiBackdropPaths || tmdbApiBackdropPaths.length === 0) {
|
|
return false;
|
|
}
|
|
const match = tmdbApiBackdropPaths.some(apiPath => apiPath === showboxImagePath);
|
|
if (match) {
|
|
console.log(` [ImageValidation] SUCCESS: ShowBox image path "${showboxImagePath}" matches a TMDB API backdrop path.`);
|
|
} else {
|
|
console.log(` [ImageValidation] FAILED: ShowBox image path "${showboxImagePath}" does not match any of TMDB API backdrop paths (${tmdbApiBackdropPaths.slice(0, 3).join(', ')}...).`);
|
|
}
|
|
return match;
|
|
};
|
|
|
|
|
|
|
|
// Function to extract special title forms for anime (e.g., Romaji)
|
|
const extractSpecialTitles = (tmdbData, alternativeTitles = []) => {
|
|
const specialTitles = [];
|
|
console.log(` [TITLE] Alternative titles available: ${alternativeTitles.length}`);
|
|
if (alternativeTitles.length > 0) {
|
|
console.log(` [TITLE] Available alternatives: ${alternativeTitles.map(t => `"${t.title}" (${t.iso_3166_1 || 'unknown'}-${t.iso_639_1 || 'unknown'})`).join(', ')}`);
|
|
}
|
|
|
|
// Get original title
|
|
const originalTitle = tmdbData.original_title || tmdbData.original_name || '';
|
|
const originalLanguage = tmdbData.original_language || '';
|
|
|
|
// Check if original title uses non-Latin script
|
|
// This regex covers most non-Latin scripts: CJK (Chinese, Japanese, Korean), Arabic, Cyrillic, etc.
|
|
const hasNonLatinChars = /[\u0400-\u04FF\u0500-\u052F\u1100-\u11FF\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3130-\u318F\u31F0-\u31FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]/.test(originalTitle);
|
|
|
|
if (hasNonLatinChars) {
|
|
console.log(` [ROMAN] Detected non-Latin title: "${originalTitle}" (Language: ${originalLanguage}). Looking for Romanized version.`);
|
|
|
|
// Get language name for better logging
|
|
const languageNames = {
|
|
'ko': 'Korean',
|
|
'ja': 'Japanese',
|
|
'zh': 'Chinese',
|
|
'ru': 'Russian',
|
|
'ar': 'Arabic',
|
|
'th': 'Thai',
|
|
'hi': 'Hindi'
|
|
// Add more as needed
|
|
};
|
|
const languageName = languageNames[originalLanguage] || originalLanguage;
|
|
|
|
// STEP 1: Try to find explicitly labeled romanized titles
|
|
const romanizedCandidates = alternativeTitles.filter(alt =>
|
|
// Any alt title labeled as "Romanized" or has "Romaji"/"Romanization" in type
|
|
(alt.type && (
|
|
alt.type.toLowerCase().includes('roman') ||
|
|
alt.type.toLowerCase().includes('romaji')
|
|
)) ||
|
|
// Special case: original language country code with Latin script
|
|
(alt.iso_3166_1 === originalLanguage.toUpperCase() &&
|
|
/^[a-zA-Z0-9\s\-:;,.!?()&'"]+$/.test(alt.title) &&
|
|
alt.iso_639_1 !== 'en')
|
|
);
|
|
|
|
if (romanizedCandidates.length > 0) {
|
|
romanizedCandidates.forEach(rc => {
|
|
console.log(` [ROMAN] Found labeled Romanized title: "${rc.title}" (${rc.iso_3166_1 || 'unknown'}-${rc.iso_639_1 || 'unknown'}${rc.type ? ', Type: ' + rc.type : ''})`);
|
|
specialTitles.push(rc.title);
|
|
});
|
|
} else {
|
|
console.log(` [ROMAN] No explicitly labeled Romanized titles found in alternatives.`);
|
|
|
|
// STEP 2: Identify Romanized by language and character set
|
|
// For non-Latin scripts, look for titles that:
|
|
// 1. Use Latin script
|
|
// 2. Are from the same country/language (if available)
|
|
// 3. Are not English
|
|
const nonEnglishLatinTitles = alternativeTitles.filter(alt =>
|
|
// Must use Latin script
|
|
/^[a-zA-Z0-9\s\-:;,.!?()&'"]+$/.test(alt.title) &&
|
|
// Should not be English if we can determine it
|
|
(alt.iso_639_1 !== 'en' || !alt.iso_639_1)
|
|
);
|
|
|
|
// Try to find titles from the same country/region first
|
|
const sameRegionTitles = nonEnglishLatinTitles.filter(alt =>
|
|
alt.iso_3166_1 &&
|
|
(alt.iso_3166_1 === originalLanguage.toUpperCase() ||
|
|
// Special case for languages that don't map directly to country codes
|
|
(originalLanguage === 'zh' && ['CN', 'TW', 'HK'].includes(alt.iso_3166_1)) ||
|
|
(originalLanguage === 'ja' && alt.iso_3166_1 === 'JP') ||
|
|
(originalLanguage === 'ko' && alt.iso_3166_1 === 'KR'))
|
|
);
|
|
|
|
if (sameRegionTitles.length > 0) {
|
|
sameRegionTitles.forEach(title => {
|
|
console.log(` [ROMAN] Found likely ${languageName} Romanized title: "${title.title}" (matched region)`);
|
|
specialTitles.push(title.title);
|
|
});
|
|
} else if (nonEnglishLatinTitles.length > 0) {
|
|
// If no same-region titles, try any non-English Latin title
|
|
// Prefer longer titles as they're often more descriptive/accurate
|
|
nonEnglishLatinTitles.sort((a, b) => b.title.length - a.title.length);
|
|
const bestCandidate = nonEnglishLatinTitles[0];
|
|
console.log(` [ROMAN] Using best non-English Latin title as Romanized version: "${bestCandidate.title}"`);
|
|
specialTitles.push(bestCandidate.title);
|
|
} else {
|
|
// STEP 3: Language-specific fallbacks based on patterns
|
|
// For Korean: Look for titles with particles like "-eui", "-ui", "-ga", "-reul", etc.
|
|
// For Japanese: Look for titles with particles like "no", "ga", "wo", "ni", etc.
|
|
// For Chinese: Look for titles with pinyin patterns
|
|
|
|
const languagePatterns = {
|
|
'ko': /\b(ui|eui|ga|reul|neun|eun|leul|seo|e|ro)\b/i, // Korean particles
|
|
'ja': /\b(no|ga|wo|ni|to|wa|ka|he|mo|de|kun|san|chan|sama|sensei)\b/i, // Japanese particles
|
|
'zh': /\b(de|le|ba|ma|ne|ge|zai|shi)\b/i // Common Mandarin particles
|
|
};
|
|
|
|
if (languagePatterns[originalLanguage]) {
|
|
const patternMatches = alternativeTitles.filter(alt =>
|
|
// Must use Latin script
|
|
/^[a-zA-Z0-9\s\-:;,.!?()&'"]+$/.test(alt.title) &&
|
|
// Should match language pattern
|
|
languagePatterns[originalLanguage].test(alt.title.toLowerCase())
|
|
);
|
|
|
|
if (patternMatches.length > 0) {
|
|
patternMatches.forEach(match => {
|
|
console.log(` [ROMAN] Found likely ${languageName} Romanized title: "${match.title}" (matched language particles)`);
|
|
specialTitles.push(match.title);
|
|
});
|
|
} else {
|
|
console.log(` [ROMAN] No titles matching ${languageName} language patterns found.`);
|
|
|
|
// STEP 4: Last resort - use any longer alternative title in Latin script
|
|
const latinTitles = alternativeTitles.filter(alt =>
|
|
/^[a-zA-Z0-9\s\-:;,.!?()&'"]+$/.test(alt.title)
|
|
);
|
|
|
|
if (latinTitles.length > 0) {
|
|
// Prefer longer titles as they're often the romanized version
|
|
latinTitles.sort((a, b) => b.title.length - a.title.length);
|
|
const longestLatin = latinTitles[0];
|
|
console.log(` [ROMAN] Using longest Latin script title as fallback: "${longestLatin.title}"`);
|
|
specialTitles.push(longestLatin.title);
|
|
} else {
|
|
console.log(` [ROMAN] Could not find any suitable Romanized alternative for "${originalTitle}"`);
|
|
}
|
|
}
|
|
} else {
|
|
// For languages without specific patterns, just use the longest Latin title
|
|
const latinTitles = alternativeTitles.filter(alt =>
|
|
/^[a-zA-Z0-9\s\-:;,.!?()&'"]+$/.test(alt.title)
|
|
);
|
|
|
|
if (latinTitles.length > 0) {
|
|
// Prefer longer titles as they're often the romanized version
|
|
latinTitles.sort((a, b) => b.title.length - a.title.length);
|
|
const longestLatin = latinTitles[0];
|
|
console.log(` [ROMAN] Using longest Latin script title for ${languageName}: "${longestLatin.title}"`);
|
|
specialTitles.push(longestLatin.title);
|
|
} else {
|
|
console.log(` [ROMAN] Could not find any suitable Romanized alternative for "${originalTitle}"`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (originalLanguage && originalLanguage !== 'en') {
|
|
// For Latin-script non-English titles, still check for alternative titles
|
|
console.log(` [TITLE] Original title already uses Latin script: "${originalTitle}" (${originalLanguage})`);
|
|
|
|
// Some Latin-script languages might have alternative spellings/titles worth trying
|
|
const nonEnglishAlts = alternativeTitles.filter(alt =>
|
|
alt.iso_639_1 && alt.iso_639_1 !== 'en' && alt.title !== originalTitle
|
|
);
|
|
|
|
if (nonEnglishAlts.length > 0) {
|
|
nonEnglishAlts.forEach(alt => {
|
|
console.log(` [TITLE] Adding non-English alternative title: "${alt.title}"`);
|
|
specialTitles.push(alt.title);
|
|
});
|
|
}
|
|
}
|
|
|
|
return specialTitles;
|
|
};
|
|
|
|
// NEW FUNCTION: Search ShowBox and extract the most relevant URL
|
|
const _searchAndExtractShowboxUrl = async (searchTerm, originalTmdbTitle, mediaYear, showboxScraperInstance, tmdbType, regionPreference = null, tmdbAllTitles = []) => {
|
|
const cacheSubDir = 'showbox_search_results';
|
|
|
|
// mediaTypeString removed (was unused after scoring refactor)
|
|
|
|
// Add a cache version to invalidate previous incorrect cached results
|
|
const CACHE_VERSION = "v15"; // Increment this whenever the search algorithm significantly changes
|
|
|
|
// Create a proper hash for the cache key to avoid filename issues with special characters
|
|
const cacheKeyData = `${CACHE_VERSION}_${tmdbType}_${originalTmdbTitle}_${mediaYear || 'noYear'}`;
|
|
const cacheKeyHash = crypto.createHash('md5').update(cacheKeyData).digest('hex');
|
|
const searchTermKey = `${cacheKeyHash}.txt`;
|
|
|
|
// Log what we're looking for to help with debugging
|
|
console.log(` Searching for ShowBox match for: "${originalTmdbTitle}" (${mediaYear || 'N/A'}) [Cache key: ${cacheKeyHash}]`);
|
|
|
|
// Check if DISABLE_CACHE is set to 'true'
|
|
if (process.env.DISABLE_CACHE !== 'true') {
|
|
const cachedResult = await getFromCache(searchTermKey, cacheSubDir);
|
|
if (cachedResult) {
|
|
// Handle both old string format and new object format
|
|
if (typeof cachedResult === 'string') {
|
|
console.log(` CACHE HIT for ShowBox search best match URL (${originalTmdbTitle} ${mediaYear || ''}): ${cachedResult}`);
|
|
if (cachedResult === 'NO_MATCH_FOUND') return { url: null, score: -1 };
|
|
return { url: cachedResult, score: 10 };
|
|
} else if (cachedResult.result === 'NO_MATCH_FOUND') {
|
|
// For stream searches, always retry immediately - content availability can change anytime
|
|
console.log(` CACHE HIT: Previously no match found for ${originalTmdbTitle} ${mediaYear || ''}, but retrying immediately for fresh content.`);
|
|
// Continue to search logic below to retry immediately
|
|
} else {
|
|
console.log(` CACHE HIT for ShowBox search best match URL (${originalTmdbTitle} ${mediaYear || ''}): ${cachedResult}`);
|
|
return { url: cachedResult, score: 10 };
|
|
}
|
|
}
|
|
} else {
|
|
console.log(` Cache disabled, skipping cache check for ShowBox search.`);
|
|
}
|
|
|
|
// Special characters often cause search issues, create a cleaned version of the search term
|
|
// Replace special characters with spaces, ensure words are properly separated
|
|
const cleanedSearchTerm = searchTerm.replace(/[&\-_:;,.]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
console.log(` Using cleaned search term: "${cleanedSearchTerm}" (Original: "${searchTerm}")`);
|
|
|
|
// Try multiple search strategies if needed
|
|
const searchStrategies = [];
|
|
|
|
// Track strategy effectiveness (could be persisted to disk in a production system)
|
|
// Higher priority strategies should be tried first
|
|
const STRATEGY_PRIORITIES = {
|
|
"original_with_year": 10,
|
|
"cleaned_with_year": 9,
|
|
"special_title_with_year": 8,
|
|
"original_only": 7,
|
|
"and_replacement_with_year": 6,
|
|
"first_part_with_year": 5,
|
|
"alternative_with_year": 4,
|
|
"alternative_only": 3,
|
|
"first_word_with_year": 2,
|
|
"year_only": 1
|
|
};
|
|
|
|
// STRATEGY 1: Original TMDB title with year (highest priority)
|
|
if (mediaYear) {
|
|
searchStrategies.push({
|
|
term: `${originalTmdbTitle} ${mediaYear}`,
|
|
description: "original TMDB title with year",
|
|
priority: STRATEGY_PRIORITIES.original_with_year
|
|
});
|
|
}
|
|
|
|
// STRATEGY 2: Cleaned search term with year
|
|
if (mediaYear) {
|
|
searchStrategies.push({
|
|
term: `${cleanedSearchTerm} ${mediaYear}`,
|
|
description: "cleaned search term with year",
|
|
priority: STRATEGY_PRIORITIES.cleaned_with_year
|
|
});
|
|
} else {
|
|
searchStrategies.push({
|
|
term: cleanedSearchTerm,
|
|
description: "cleaned search term",
|
|
priority: STRATEGY_PRIORITIES.cleaned_with_year
|
|
});
|
|
}
|
|
|
|
// STRATEGY 3: Special titles (like anime romanizations) with year
|
|
const specialTitlesFromAll = tmdbAllTitles.filter(title =>
|
|
title !== originalTmdbTitle &&
|
|
(title.length > originalTmdbTitle.length ||
|
|
/[\u0080-\uFFFF]/.test(originalTmdbTitle))
|
|
).slice(0, 2);
|
|
|
|
specialTitlesFromAll.forEach((specialTitle, idx) => {
|
|
if (mediaYear) {
|
|
searchStrategies.push({
|
|
term: `${specialTitle} ${mediaYear}`,
|
|
description: `special title ${idx + 1} with year`,
|
|
priority: STRATEGY_PRIORITIES.special_title_with_year
|
|
});
|
|
}
|
|
});
|
|
|
|
// STRATEGY 4: Original TMDB title only
|
|
searchStrategies.push({
|
|
term: originalTmdbTitle,
|
|
description: "original TMDB title only",
|
|
priority: STRATEGY_PRIORITIES.original_only
|
|
});
|
|
|
|
// STRATEGY 5: For titles with "&", try "and" replacement
|
|
if (originalTmdbTitle.includes('&')) {
|
|
const andTitle = originalTmdbTitle.replace(/&/g, 'and');
|
|
if (mediaYear) {
|
|
searchStrategies.push({
|
|
term: `${andTitle} ${mediaYear}`,
|
|
description: "& replaced with 'and', with year",
|
|
priority: STRATEGY_PRIORITIES.and_replacement_with_year
|
|
});
|
|
}
|
|
}
|
|
|
|
// STRATEGY 6: First part before "&" with year
|
|
if (originalTmdbTitle.includes('&')) {
|
|
const firstPart = originalTmdbTitle.split('&')[0].trim();
|
|
if (firstPart.length > 3 && mediaYear) {
|
|
searchStrategies.push({
|
|
term: `${firstPart} ${mediaYear}`,
|
|
description: "first part before &, with year",
|
|
priority: STRATEGY_PRIORITIES.first_part_with_year
|
|
});
|
|
}
|
|
}
|
|
|
|
// STRATEGY 7-8: Alternative titles (limit to 2 alternatives to reduce API calls)
|
|
const limitedAlternatives = tmdbAllTitles
|
|
.filter(altTitle => altTitle && altTitle !== originalTmdbTitle)
|
|
.slice(0, 2);
|
|
|
|
limitedAlternatives.forEach((altTitle, index) => {
|
|
if (mediaYear) {
|
|
searchStrategies.push({
|
|
term: `${altTitle} ${mediaYear}`,
|
|
description: `alternative title ${index + 1} with year`,
|
|
priority: STRATEGY_PRIORITIES.alternative_with_year
|
|
});
|
|
}
|
|
searchStrategies.push({
|
|
term: altTitle,
|
|
description: `alternative title ${index + 1}`,
|
|
priority: STRATEGY_PRIORITIES.alternative_only
|
|
});
|
|
});
|
|
|
|
// STRATEGY 9: First word with year (for potentially shortened titles)
|
|
const titleWords = originalTmdbTitle.split(/\s+/);
|
|
if (titleWords.length > 1) {
|
|
const firstWord = titleWords[0];
|
|
if (firstWord.length > 3 && !searchStrategies.some(s => s.term === firstWord) && mediaYear) {
|
|
searchStrategies.push({
|
|
term: `${firstWord} ${mediaYear}`,
|
|
description: "first word of title with year",
|
|
priority: STRATEGY_PRIORITIES.first_word_with_year
|
|
});
|
|
}
|
|
}
|
|
|
|
// STRATEGY 10: Year only (last resort for popular movies)
|
|
if (mediaYear) {
|
|
searchStrategies.push({
|
|
term: mediaYear,
|
|
description: "year only (for popular movies)",
|
|
priority: STRATEGY_PRIORITIES.year_only
|
|
});
|
|
}
|
|
|
|
// Sort strategies by priority
|
|
searchStrategies.sort((a, b) => b.priority - a.priority);
|
|
|
|
// For debugging
|
|
console.log(` Search strategies in order of priority:`);
|
|
searchStrategies.forEach((strategy, idx) => {
|
|
console.log(` ${idx + 1}. ${strategy.description} (Priority: ${strategy.priority}): "${strategy.term}"`);
|
|
});
|
|
|
|
let bestResult = { url: null, score: -1, strategy: null };
|
|
|
|
// Generate all possible slugs from TMDB titles
|
|
const allPossibleSlugs = tmdbAllTitles.map(title => slugify(title)).filter(Boolean);
|
|
console.log(` Generated ${allPossibleSlugs.length} possible slugs for matching: ${allPossibleSlugs.join(', ')}`);
|
|
|
|
// Track strategy effectiveness
|
|
const strategyResults = {};
|
|
|
|
// Limit the number of strategies to try (to reduce API calls)
|
|
const MAX_STRATEGIES_TO_TRY = 5;
|
|
let strategiesAttempted = 0;
|
|
|
|
for (const strategy of searchStrategies) {
|
|
// Limit the number of strategies we try
|
|
if (strategiesAttempted >= MAX_STRATEGIES_TO_TRY) {
|
|
console.log(` Reached maximum number of search strategies (${MAX_STRATEGIES_TO_TRY}). Stopping search.`);
|
|
break;
|
|
}
|
|
|
|
strategiesAttempted++;
|
|
const searchUrl = `https://www.showbox.media/search?keyword=${encodeURIComponent(strategy.term)}`;
|
|
console.log(` Searching ShowBox with URL: ${searchUrl} (Strategy: ${strategy.description})`);
|
|
|
|
const htmlContent = await showboxScraperInstance._makeRequest(searchUrl);
|
|
if (!htmlContent) {
|
|
console.log(` Failed to fetch ShowBox search results for strategy: ${strategy.description}`);
|
|
// Track failed strategy
|
|
strategyResults[strategy.description] = { success: false, score: 0 };
|
|
continue; // Try next strategy
|
|
}
|
|
|
|
const $ = cheerio.load(htmlContent);
|
|
const searchResults = [];
|
|
|
|
// Helper for simple string similarity (case-insensitive, removes non-alphanumeric)
|
|
const simplifyString = (str) => str.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
const simplifiedTmdbTitle = simplifyString(originalTmdbTitle);
|
|
|
|
// Create normalized versions of all TMDB titles for comparison
|
|
const normalizedTmdbTitles = tmdbAllTitles.map(title => normalizeTitleForComparison(title));
|
|
|
|
$('div.film-poster').each((i, elem) => {
|
|
const linkElement = $(elem).find('a.film-poster-ahref');
|
|
const itemTitle = linkElement.attr('title');
|
|
const itemHref = linkElement.attr('href');
|
|
|
|
if (itemTitle && itemHref) {
|
|
const simplifiedItemTitle = simplifyString(itemTitle);
|
|
const normalizedItemTitle = normalizeTitleForComparison(itemTitle);
|
|
|
|
// Extract slug from URL
|
|
const urlSlugMatch = itemHref.match(/\/(movie|tv)\/([mt]-[a-z0-9-]+-\d{4})/);
|
|
const itemSlug = urlSlugMatch ? urlSlugMatch[2] : '';
|
|
|
|
// Check if the slug matches any of our generated slugs
|
|
const slugMatchScore = allPossibleSlugs.some(slug =>
|
|
itemSlug.includes(slug) || // Direct inclusion of our slug in their slug
|
|
allPossibleSlugs.some(ourSlug => ourSlug.includes(itemSlug.replace(/^[mt]-/, ''))) // Their slug in our slug
|
|
) ? 10 : 0;
|
|
|
|
// Attempt to extract year from title if present, e.g., "Title (YYYY)"
|
|
let itemYear = null;
|
|
const yearMatch = itemTitle.match(/\((\d{4})\)$/);
|
|
if (yearMatch && yearMatch[1]) {
|
|
itemYear = yearMatch[1];
|
|
}
|
|
|
|
// Extract year from URL if possible
|
|
if (!itemYear && itemHref) {
|
|
const urlYearMatch = itemHref.match(/-(20\d{2})$/);
|
|
if (urlYearMatch) {
|
|
itemYear = urlYearMatch[1];
|
|
}
|
|
}
|
|
|
|
// IMPROVED SCORING LOGIC
|
|
let score = 0;
|
|
|
|
// Extra points for URLs that match our expected pattern
|
|
const correctMediaType = (tmdbType === 'movie' && itemHref.includes('/movie/')) ||
|
|
(tmdbType === 'tv' && itemHref.includes('/tv/'));
|
|
|
|
// Strong bonus for matching the expected media type
|
|
if (correctMediaType) {
|
|
score += 12; // Significantly increase importance of correct media type
|
|
} else {
|
|
score -= 15; // Heavy penalty for wrong media type
|
|
}
|
|
|
|
// Strong bonus for slug match
|
|
score += slugMatchScore;
|
|
|
|
// Exact title match (any of our titles)
|
|
if (normalizedTmdbTitles.some(normTitle => normTitle === normalizedItemTitle)) {
|
|
score += 15; // Very strong bonus for exact normalized match
|
|
}
|
|
// Title contains our title or vice versa (any of our titles)
|
|
else if (normalizedTmdbTitles.some(normTitle =>
|
|
normTitle.length > 3 && normalizedItemTitle.length > 3 &&
|
|
(normalizedItemTitle.includes(normTitle) || normTitle.includes(normalizedItemTitle))
|
|
)) {
|
|
score += 10; // Good bonus for partial match
|
|
}
|
|
// Simplified title contains the entire simplified TMDB title
|
|
else if (simplifiedItemTitle.includes(simplifiedTmdbTitle)) {
|
|
score += 7; // Moderate bonus
|
|
}
|
|
// TMDB title contains the simplified item title (could be abbreviation/shortened)
|
|
else if (simplifiedTmdbTitle.includes(simplifiedItemTitle) && simplifiedItemTitle.length > 3) {
|
|
score += 3; // Small bonus for being contained in the TMDB title
|
|
}
|
|
|
|
// Word-by-word match calculation for multi-word titles
|
|
const tmdbWords = originalTmdbTitle.toLowerCase().split(/\s+/);
|
|
const itemWords = itemTitle.toLowerCase().split(/\s+/);
|
|
|
|
let wordMatchCount = 0;
|
|
for (const tmdbWord of tmdbWords) {
|
|
if (tmdbWord.length <= 2) continue; // Skip very short words
|
|
// More strict word matching - require exact word matches or very close matches
|
|
if (itemWords.some(itemWord => {
|
|
// Exact match
|
|
if (itemWord === tmdbWord) return true;
|
|
// Allow for very close matches (like plurals or slight variations)
|
|
if (tmdbWord.length > 3 && itemWord.length > 3) {
|
|
return (itemWord.includes(tmdbWord) && itemWord.length - tmdbWord.length <= 2) ||
|
|
(tmdbWord.includes(itemWord) && tmdbWord.length - itemWord.length <= 2);
|
|
}
|
|
return false;
|
|
})) {
|
|
wordMatchCount++;
|
|
}
|
|
}
|
|
|
|
// Add score based on percentage of words matched, but require at least some match
|
|
if (tmdbWords.length > 0 && wordMatchCount > 0) {
|
|
const wordMatchPercent = wordMatchCount / tmdbWords.length;
|
|
score += wordMatchPercent * 5; // Up to 5 points for word matches
|
|
} else if (tmdbWords.length > 1 && wordMatchCount === 0) {
|
|
// Heavy penalty for multi-word titles with no word matches
|
|
score -= 10;
|
|
}
|
|
|
|
// YEAR MATCHING - now much more important
|
|
if (mediaYear && itemYear) {
|
|
if (mediaYear === itemYear) {
|
|
score += 18; // MUCH stronger bonus for exact year match
|
|
} else {
|
|
// Penalty for year mismatch, larger for bigger differences
|
|
const yearDiff = Math.abs(parseInt(mediaYear) - parseInt(itemYear));
|
|
if (yearDiff <= 1) {
|
|
score -= 3; // Small penalty for 1 year difference
|
|
} else if (yearDiff <= 3) {
|
|
score -= 10; // Medium penalty for 2-3 year difference
|
|
} else {
|
|
score -= 20; // Large penalty for > 3 year difference
|
|
}
|
|
}
|
|
} else if (mediaYear && !itemYear) {
|
|
// If we have a year but the item doesn't, apply a small penalty
|
|
score -= 5;
|
|
}
|
|
|
|
searchResults.push({
|
|
title: itemTitle,
|
|
href: itemHref,
|
|
year: itemYear,
|
|
score: score,
|
|
slug: itemSlug,
|
|
isMovie: itemHref.includes('/movie/'),
|
|
isTv: itemHref.includes('/tv/')
|
|
});
|
|
}
|
|
});
|
|
|
|
// Sort by score and pick the best one
|
|
searchResults.sort((a, b) => b.score - a.score);
|
|
|
|
if (searchResults.length > 0) {
|
|
console.log(` Search results for strategy "${strategy.description}":`);
|
|
searchResults.slice(0, 3).forEach((result, i) => {
|
|
console.log(` ${i + 1}. Title: "${result.title}", Year: ${result.year || 'N/A'}, Score: ${result.score.toFixed(1)}, URL: ${result.href}, Media Type: ${result.isMovie ? 'Movie' : (result.isTv ? 'TV' : 'Unknown')}`);
|
|
});
|
|
|
|
const bestMatch = searchResults[0];
|
|
if (bestMatch.score > bestResult.score) {
|
|
bestResult = {
|
|
url: `https://www.showbox.media${bestMatch.href}`,
|
|
score: bestMatch.score,
|
|
strategy: strategy.description,
|
|
title: bestMatch.title,
|
|
year: bestMatch.year,
|
|
isCorrectType: (tmdbType === 'movie' && bestMatch.isMovie) || (tmdbType === 'tv' && bestMatch.isTv)
|
|
};
|
|
|
|
// Track successful strategy
|
|
strategyResults[strategy.description] = {
|
|
success: true,
|
|
score: bestMatch.score,
|
|
correctType: bestResult.isCorrectType
|
|
};
|
|
} else {
|
|
// Track strategy that didn't beat our current best
|
|
strategyResults[strategy.description] = {
|
|
success: true,
|
|
score: bestMatch.score,
|
|
correctType: (tmdbType === 'movie' && bestMatch.isMovie) || (tmdbType === 'tv' && bestMatch.isTv),
|
|
notBest: true
|
|
};
|
|
}
|
|
} else {
|
|
console.log(` No results found for strategy "${strategy.description}"`);
|
|
// Track failed strategy
|
|
strategyResults[strategy.description] = { success: false, score: 0 };
|
|
}
|
|
|
|
// INCREASED THRESHOLD: Require higher confidence for matches to avoid false positives
|
|
if (bestResult.score > 35 && bestResult.isCorrectType) {
|
|
console.log(` Found good match with score ${bestResult.score.toFixed(1)} using strategy "${bestResult.strategy}", stopping search`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Log strategy effectiveness summary
|
|
console.log(` Strategy effectiveness summary:`);
|
|
Object.entries(strategyResults).forEach(([strategy, result]) => {
|
|
if (result.success) {
|
|
const status = result.notBest ? "Found results but not best" :
|
|
(result.correctType ? "Found correct type" : "Found wrong type");
|
|
console.log(` - ${strategy}: ${status} (Score: ${result.score.toFixed(1)})`);
|
|
} else {
|
|
console.log(` - ${strategy}: No results found`);
|
|
}
|
|
});
|
|
|
|
// Final decision based on all strategies
|
|
if (bestResult.url) {
|
|
let useResult = false;
|
|
|
|
// Use score-based confidence for validation - increased threshold to prevent false positives
|
|
if (bestResult.score >= 40 && bestResult.isCorrectType) {
|
|
useResult = true;
|
|
} else {
|
|
console.log(` Low confidence match (Score: ${bestResult.score.toFixed(1)}). Discarding - requires score >= 40.`);
|
|
useResult = false;
|
|
}
|
|
|
|
if (useResult && bestResult.url) {
|
|
console.log(` Best overall match: ${bestResult.url} (Score: ${bestResult.score.toFixed(1)}, Strategy: ${bestResult.strategy})`);
|
|
|
|
// Save high-score results to cache
|
|
if (process.env.DISABLE_CACHE !== 'true') {
|
|
await saveToCache(searchTermKey, bestResult.url, cacheSubDir);
|
|
}
|
|
return { url: bestResult.url, score: bestResult.score };
|
|
}
|
|
}
|
|
|
|
// This block will now be reached if the initial search found nothing, or if AI rejected the candidate.
|
|
console.log(` No suitable match found on ShowBox search for: ${originalTmdbTitle} (${mediaYear || 'N/A'})`);
|
|
if (process.env.DISABLE_CACHE !== 'true') {
|
|
await saveToCache(searchTermKey, { result: 'NO_MATCH_FOUND', timestamp: Date.now() }, cacheSubDir);
|
|
}
|
|
return { url: null, score: -1 };
|
|
};
|
|
|
|
// TMDB helper function to get ShowBox URL from TMDB ID
|
|
// MODIFICATION: Enhanced for better anime and title matching
|
|
const getShowboxUrlFromTmdbInfo = async (tmdbType, tmdbId, regionPreference = null) => {
|
|
const urlCacheSubDir = 'showbox_final_url';
|
|
const urlCacheKey = `v3_${tmdbType}_${tmdbId}.json`;
|
|
const disableCache = process.env.DISABLE_CACHE === 'true';
|
|
|
|
// 1. Check for the final, validated URL in the cache first.
|
|
if (!disableCache) {
|
|
const cachedResult = await getFromCache(urlCacheKey, urlCacheSubDir);
|
|
if (cachedResult) {
|
|
if (cachedResult.showboxUrl === 'NO_URL_FOUND') {
|
|
// For stream searches, always retry immediately - content availability can change anytime
|
|
console.log(` [Final URL Cache] HIT: Previously no URL found for ${tmdbType}/${tmdbId}, but retrying immediately for fresh content.`);
|
|
// Continue to search logic below to retry immediately
|
|
} else {
|
|
console.log(` [Final URL Cache] HIT: Found cached ShowBox result for ${tmdbType}/${tmdbId}.`);
|
|
return cachedResult;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. If no cached final URL, proceed with the discovery logic.
|
|
console.time('getShowboxUrlFromTmdbInfo_total');
|
|
const mainCacheSubDir = 'tmdb_api';
|
|
const mainCacheKey = `tmdb-${tmdbType}-${tmdbId}.json`;
|
|
let tmdbData = await getFromCache(mainCacheKey, mainCacheSubDir);
|
|
|
|
if (!tmdbData || process.env.DISABLE_CACHE === 'true') {
|
|
const TMDB_API_KEY = obtainTmdbKey();
|
|
const tmdbApiUrl = `${TMDB_BASE_URL}/${tmdbType}/${tmdbId}?api_key=${TMDB_API_KEY}`;
|
|
console.log(` Fetching TMDB data from: ${tmdbApiUrl}`);
|
|
try {
|
|
const response = await axios.get(tmdbApiUrl, { timeout: 10000 });
|
|
tmdbData = response.data;
|
|
if (tmdbData) {
|
|
if (process.env.DISABLE_CACHE !== 'true') {
|
|
await saveToCache(mainCacheKey, tmdbData, mainCacheSubDir);
|
|
}
|
|
} else {
|
|
console.log(' No TMDB data received.');
|
|
console.timeEnd('getShowboxUrlFromTmdbInfo_total');
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
console.log(` Error fetching TMDB main data: ${error.message}`);
|
|
console.timeEnd('getShowboxUrlFromTmdbInfo_total');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (!tmdbData) {
|
|
console.log(` Could not fetch TMDB data for ${tmdbType}/${tmdbId}. Cannot proceed.`);
|
|
console.timeEnd('getShowboxUrlFromTmdbInfo_total');
|
|
return null;
|
|
}
|
|
|
|
// Fetch alternative titles
|
|
const altTitlesCacheKey = `tmdb-${tmdbType}-${tmdbId}-alternatives.json`;
|
|
let tmdbAlternativeTitlesData = await getFromCache(altTitlesCacheKey, mainCacheSubDir);
|
|
|
|
if (!tmdbAlternativeTitlesData || process.env.DISABLE_CACHE === 'true') {
|
|
const TMDB_API_KEY = obtainTmdbKey();
|
|
const altTitlesApiUrl = `${TMDB_BASE_URL}/${tmdbType}/${tmdbId}/alternative_titles?api_key=${TMDB_API_KEY}`;
|
|
console.log(` Fetching TMDB alternative titles from: ${altTitlesApiUrl}`);
|
|
try {
|
|
const response = await axios.get(altTitlesApiUrl, { timeout: 10000 });
|
|
tmdbAlternativeTitlesData = response.data;
|
|
if (tmdbAlternativeTitlesData) {
|
|
if (process.env.DISABLE_CACHE !== 'true') {
|
|
await saveToCache(altTitlesCacheKey, tmdbAlternativeTitlesData, mainCacheSubDir);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log(` Error fetching TMDB alternative titles: ${error.message}`);
|
|
tmdbAlternativeTitlesData = { titles: [] };
|
|
}
|
|
}
|
|
|
|
// Get alternative titles array, or empty array if none
|
|
const alternativeTitles = tmdbAlternativeTitlesData?.titles || [];
|
|
|
|
// NEW: Fetch TMDB images
|
|
const imagesCacheKey = `tmdb-${tmdbType}-${tmdbId}-images.json`;
|
|
let tmdbImagesData = await getFromCache(imagesCacheKey, mainCacheSubDir);
|
|
let tmdbBackdropPaths = [];
|
|
|
|
if (!tmdbImagesData || process.env.DISABLE_CACHE === 'true') {
|
|
const TMDB_API_KEY = obtainTmdbKey();
|
|
const imagesApiUrl = `${TMDB_BASE_URL}/${tmdbType}/${tmdbId}/images?api_key=${TMDB_API_KEY}`;
|
|
console.log(` Fetching TMDB images from: ${imagesApiUrl}`);
|
|
try {
|
|
const response = await axios.get(imagesApiUrl, { timeout: 10000 });
|
|
tmdbImagesData = response.data;
|
|
if (tmdbImagesData) {
|
|
if (process.env.DISABLE_CACHE !== 'true') {
|
|
await saveToCache(imagesCacheKey, tmdbImagesData, mainCacheSubDir);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log(` Error fetching TMDB images: ${error.message}`);
|
|
tmdbImagesData = { backdrops: [], posters: [] }; // Default to empty if error
|
|
}
|
|
}
|
|
if (tmdbImagesData && tmdbImagesData.backdrops) {
|
|
tmdbBackdropPaths = tmdbImagesData.backdrops.map(img => img.file_path).filter(Boolean);
|
|
console.log(` Collected ${tmdbBackdropPaths.length} TMDB backdrop paths.`);
|
|
if (tmdbBackdropPaths.length > 0) {
|
|
console.log(` Sample TMDB backdrop paths: ${tmdbBackdropPaths.slice(0, 3).join(', ')}`);
|
|
}
|
|
}
|
|
|
|
// Log anime genres to help identify anime content
|
|
if (tmdbData.genres && Array.isArray(tmdbData.genres)) {
|
|
const animeGenre = tmdbData.genres.find(g => g.name === 'Animation');
|
|
if (animeGenre) {
|
|
console.log(` [ANIME] Content identified as Animation genre, will check for anime-specific titles`);
|
|
}
|
|
}
|
|
|
|
// Extract main titles and year
|
|
let mainTitle = null; // Primary/localized title
|
|
let originalTitle = null; // Original title (could be in non-Latin script)
|
|
let year = null;
|
|
|
|
if (tmdbType === 'movie') {
|
|
mainTitle = tmdbData.title; // Prioritize .title for movies (localized)
|
|
originalTitle = tmdbData.original_title;
|
|
if (tmdbData.release_date && String(tmdbData.release_date).length >= 4) {
|
|
year = String(tmdbData.release_date).substring(0, 4);
|
|
}
|
|
} else if (tmdbType === 'tv') {
|
|
mainTitle = tmdbData.name; // Prioritize .name for TV (localized)
|
|
originalTitle = tmdbData.original_name;
|
|
const rawFirstAirDate = tmdbData.first_air_date;
|
|
if (rawFirstAirDate && String(rawFirstAirDate).length >= 4) {
|
|
year = String(rawFirstAirDate).substring(0, 4);
|
|
}
|
|
}
|
|
|
|
if (!mainTitle && !originalTitle) {
|
|
console.log(` Could not determine any title from TMDB data for ${tmdbType}/${tmdbId}.`);
|
|
console.timeEnd('getShowboxUrlFromTmdbInfo_total');
|
|
return null;
|
|
}
|
|
|
|
// If mainTitle is missing, use originalTitle as fallback
|
|
if (!mainTitle) mainTitle = originalTitle;
|
|
|
|
// Extract special titles like Romaji for anime
|
|
const specialTitles = extractSpecialTitles(tmdbData, alternativeTitles);
|
|
|
|
// Collect all available titles in one array for processing
|
|
const allTitles = [mainTitle, originalTitle, ...specialTitles, ...alternativeTitles.map(alt => alt.title)]
|
|
.filter(Boolean) // Remove nulls/undefined
|
|
.filter((value, index, self) => self.indexOf(value) === index); // Remove duplicates
|
|
|
|
console.log(` Collected ${allTitles.length} unique titles for "${mainTitle || originalTitle}":`);
|
|
allTitles.forEach((title, idx) => {
|
|
console.log(` [${idx + 1}] "${title}"`);
|
|
});
|
|
|
|
const MAX_DIRECT_URL_ATTEMPTS = 3; // Configurable limit for direct URL construction
|
|
const titlesForDirectAttempt = allTitles.slice(0, MAX_DIRECT_URL_ATTEMPTS);
|
|
console.log(` Limiting direct URL construction attempts to first ${titlesForDirectAttempt.length} titles out of ${allTitles.length} total unique titles.`);
|
|
|
|
// Initialize ShowBox scraper instance
|
|
const showboxScraperInstance = new ShowBoxScraper(regionPreference);
|
|
const mediaTypeString = tmdbType === 'movie' ? 'movie' : 'tv';
|
|
const mediaTypePrefix = tmdbType === 'movie' ? 'm' : 't';
|
|
|
|
// This is a new inner function to contain all the discovery logic.
|
|
const findUrl = async () => {
|
|
// If we have a year, try direct URL construction with all titles
|
|
if (year) {
|
|
console.log(` Attempting direct ShowBox URL construction for ${tmdbType} "${mainTitle}" (${year}) with ${titlesForDirectAttempt.length} title variants.`);
|
|
|
|
for (const candidateTitle of titlesForDirectAttempt) {
|
|
const slug = slugify(candidateTitle);
|
|
if (!slug) {
|
|
console.log(` Skipping empty slug for title: "${candidateTitle}"`);
|
|
continue;
|
|
}
|
|
|
|
const directShowboxUrl = `https://www.showbox.media/${mediaTypeString}/${mediaTypePrefix}-${slug}-${year}`;
|
|
console.log(` Trying direct URL: ${directShowboxUrl} (from title: "${candidateTitle}")`);
|
|
|
|
const htmlContent = await showboxScraperInstance._makeRequest(directShowboxUrl);
|
|
if (htmlContent) {
|
|
console.log(` Successfully fetched content from direct URL: ${directShowboxUrl}`);
|
|
const pageInfo = showboxScraperInstance.extractContentIdAndType(directShowboxUrl, htmlContent);
|
|
|
|
if (pageInfo && pageInfo.title) {
|
|
const titleIsValid = validateShowboxTitle(pageInfo.title, mainTitle, originalTitle, alternativeTitles);
|
|
const showboxTmdbImagePath = extractTmdbImagePathFromShowboxHtml(htmlContent);
|
|
const imageIsValid = validateTmdbImage(showboxTmdbImagePath, tmdbBackdropPaths);
|
|
|
|
if (titleIsValid && imageIsValid) {
|
|
console.log(` SUCCESS (TITLE & IMAGE VALIDATED): Validated title and TMDB image for ${directShowboxUrl}. Using this URL.`);
|
|
return { showboxUrl: directShowboxUrl, year: year, title: mainTitle };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ... (rest of the discovery logic: special slugs, AI search, _searchAndExtractShowboxUrl call)
|
|
// Note: all 'return { showboxUrl: ... }' statements will now return from this inner function.
|
|
// ... (this part of the code remains the same but is conceptually inside findUrl)
|
|
|
|
// The original search call at the end
|
|
console.log(` No validated ShowBox URL found through direct construction. Falling back to search...`);
|
|
const searchResult = await _searchAndExtractShowboxUrl(mainTitle, mainTitle, year, showboxScraperInstance, tmdbType, regionPreference, allTitles);
|
|
if (searchResult && searchResult.url) {
|
|
console.log(` Search found a result. Title: ${searchResult.title}, Year: ${searchResult.year}`);
|
|
return { showboxUrl: searchResult.url, year: year, title: mainTitle };
|
|
}
|
|
|
|
return null; // Return null if all methods fail
|
|
};
|
|
|
|
// Execute the discovery function
|
|
const finalResult = await findUrl();
|
|
|
|
// 3. Cache the result of the discovery, whether successful or not.
|
|
if (!disableCache) {
|
|
if (finalResult && finalResult.showboxUrl) {
|
|
console.log(` [Final URL Cache] SAVING result for ${tmdbType}/${tmdbId}: ${finalResult.showboxUrl}`);
|
|
await saveToCache(urlCacheKey, finalResult, urlCacheSubDir);
|
|
} else {
|
|
console.log(` [Final URL Cache] SAVING 'NO_URL_FOUND' for ${tmdbType}/${tmdbId}.`);
|
|
await saveToCache(urlCacheKey, { showboxUrl: 'NO_URL_FOUND', timestamp: Date.now() }, urlCacheSubDir);
|
|
}
|
|
}
|
|
|
|
console.timeEnd('getShowboxUrlFromTmdbInfo_total');
|
|
return finalResult;
|
|
};
|
|
|
|
// Function to fetch sources for a single FID
|
|
// MODIFICATION: Remove scraperApiKey parameter
|
|
const fetchSourcesForSingleFid = async (fidToProcess, shareKey, regionPreference = null, userCookie = null, retryAttempt = 0) => {
|
|
const MAX_RETRIES_SAME_REGION = 1; // Retry once on the same region before falling back
|
|
|
|
const targetPostData = new URLSearchParams();
|
|
targetPostData.append('fid', fidToProcess);
|
|
targetPostData.append('share_key', shareKey);
|
|
|
|
const cookieForRequest = await getCookieForRequest(regionPreference, userCookie);
|
|
|
|
const baseHeaders = {
|
|
'Cookie': `ui=${cookieForRequest}`,
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
};
|
|
|
|
const finalPostUrl = FEBBOX_PLAYER_URL;
|
|
const postDataForAxios = targetPostData.toString();
|
|
const axiosConfig = { headers: baseHeaders, timeout: 20000 };
|
|
|
|
console.log(` Fetching fresh player data for video FID: ${fidToProcess} (Share: ${shareKey}) directly to ${FEBBOX_PLAYER_URL}`);
|
|
|
|
try {
|
|
const response = await axios.post(finalPostUrl, postDataForAxios, axiosConfig);
|
|
const playerContent = response.data;
|
|
|
|
// Mark the region as available if the request succeeded
|
|
if (global.lastRequestedRegion && global.lastRequestedRegion.used) {
|
|
global.regionAvailabilityStatus[global.lastRequestedRegion.used] = true;
|
|
// If we successfully used a fallback, notify through a global flag
|
|
if (global.lastRequestedRegion.usingFallback) {
|
|
global.usedRegionFallback = {
|
|
original: global.lastRequestedRegion.original,
|
|
fallback: global.lastRequestedRegion.used
|
|
};
|
|
}
|
|
}
|
|
|
|
const sourcesMatch = playerContent.match(/var sources = (.*?);\s*/s);
|
|
if (!sourcesMatch) {
|
|
console.log(` Could not find sources array in player response for FID ${fidToProcess}`);
|
|
if (playerContent.startsWith('http') && (playerContent.includes('.mp4') || playerContent.includes('.m3u8'))) {
|
|
return [{ "label": "DirectLink", "url": playerContent.trim() }];
|
|
}
|
|
try {
|
|
const jsonResponse = JSON.parse(playerContent);
|
|
if (jsonResponse.msg) {
|
|
console.log(` FebBox API Error: ${jsonResponse.code} - ${jsonResponse.msg}`);
|
|
// Check for region-specific errors
|
|
if (jsonResponse.code === 1002 ||
|
|
jsonResponse.msg.includes("region") ||
|
|
jsonResponse.msg.includes("location") ||
|
|
jsonResponse.msg.includes("unavailable")) {
|
|
|
|
// --- BEGIN: Retry Logic ---
|
|
if (retryAttempt < MAX_RETRIES_SAME_REGION) {
|
|
console.log(` Region error on attempt ${retryAttempt + 1}. Retrying same region: ${global.lastRequestedRegion.used}`);
|
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
|
return fetchSourcesForSingleFid(fidToProcess, shareKey, regionPreference, userCookie, retryAttempt + 1);
|
|
}
|
|
// --- END: Retry Logic ---
|
|
|
|
// Mark the region as unavailable after retries have failed
|
|
if (global.lastRequestedRegion && global.lastRequestedRegion.used) {
|
|
console.log(` Marking region ${global.lastRequestedRegion.used} as unavailable after ${retryAttempt + 1} failed attempts.`);
|
|
global.regionAvailabilityStatus[global.lastRequestedRegion.used] = false;
|
|
|
|
// Try again with a US fallback region if we haven't already
|
|
if (!global.lastRequestedRegion.usingFallback) {
|
|
for (const fallbackRegion of US_FALLBACK_REGIONS) {
|
|
if (fallbackRegion !== global.lastRequestedRegion.used) {
|
|
console.log(` Retrying with fallback US region: ${fallbackRegion}`);
|
|
// Reset retryAttempt for the new region
|
|
return fetchSourcesForSingleFid(fidToProcess, shareKey, fallbackRegion, userCookie, 0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) { /* Not a JSON error message */ }
|
|
return [];
|
|
}
|
|
|
|
const sourcesJsArrayString = sourcesMatch[1];
|
|
const sourcesData = JSON.parse(sourcesJsArrayString);
|
|
const fidVideoLinks = [];
|
|
for (const sourceItem of sourcesData) {
|
|
if (sourceItem.file && sourceItem.label) {
|
|
let detailedFilename = null;
|
|
try {
|
|
const urlParams = new URLSearchParams(new URL(sourceItem.file).search);
|
|
if (urlParams.has('KEY5')) {
|
|
detailedFilename = urlParams.get('KEY5');
|
|
}
|
|
} catch (e) {
|
|
// console.warn('Could not parse URL to get KEY5:', sourceItem.file, e.message);
|
|
}
|
|
fidVideoLinks.push({
|
|
"label": String(sourceItem.label),
|
|
"url": String(sourceItem.file),
|
|
"detailedFilename": detailedFilename
|
|
});
|
|
}
|
|
}
|
|
|
|
if (fidVideoLinks.length > 0) {
|
|
console.log(` Extracted ${fidVideoLinks.length} fresh video link(s) for FID ${fidToProcess}`);
|
|
}
|
|
return fidVideoLinks;
|
|
} catch (error) {
|
|
console.log(` Request error for FID ${fidToProcess}: ${error.message}`);
|
|
|
|
// Check for region-specific errors and mark the region as unavailable
|
|
if ((error.message.includes('timeout') ||
|
|
error.message.includes('network') ||
|
|
(error.response && (error.response.status === 403 || error.response.status === 404)))) {
|
|
|
|
// --- BEGIN: Retry Logic ---
|
|
if (retryAttempt < MAX_RETRIES_SAME_REGION) {
|
|
console.log(` Network error on attempt ${retryAttempt + 1}. Retrying same region: ${global.lastRequestedRegion.used}`);
|
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
|
return fetchSourcesForSingleFid(fidToProcess, shareKey, regionPreference, userCookie, retryAttempt + 1);
|
|
}
|
|
// --- END: Retry Logic ---
|
|
|
|
if (global.lastRequestedRegion && global.lastRequestedRegion.used) {
|
|
console.log(` Marking region ${global.lastRequestedRegion.used} as potentially unavailable after ${retryAttempt + 1} failed attempts due to error.`);
|
|
global.regionAvailabilityStatus[global.lastRequestedRegion.used] = false;
|
|
|
|
// Try again with a US fallback region if we haven't already
|
|
if (!global.lastRequestedRegion.usingFallback) {
|
|
for (const fallbackRegion of US_FALLBACK_REGIONS) {
|
|
if (fallbackRegion !== global.lastRequestedRegion.used) {
|
|
console.log(` Retrying with fallback US region: ${fallbackRegion}`);
|
|
// Reset retryAttempt for the new region
|
|
return fetchSourcesForSingleFid(fidToProcess, shareKey, fallbackRegion, userCookie, 0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(` Fresh fetch failed for FID ${fidToProcess}.`);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// ShowBox scraper class
|
|
class ShowBoxScraper {
|
|
constructor(regionPreference = null, userCookie = null) {
|
|
// Store the region preference and user cookie
|
|
this.regionPreference = regionPreference;
|
|
this.userCookie = userCookie;
|
|
// ScraperAPI key support removed
|
|
|
|
|
|
|
|
this.baseHeaders = {
|
|
'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/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
|
|
'Accept-Language': 'en-US,en;q=0.9',
|
|
'Accept-Encoding': 'gzip, deflate, br',
|
|
'Connection': 'keep-alive',
|
|
'Upgrade-Insecure-Requests': '1',
|
|
'Sec-Fetch-Dest': 'document',
|
|
'Sec-Fetch-Mode': 'navigate',
|
|
'Sec-Fetch-Site': 'same-origin',
|
|
'Sec-Fetch-User': '?1',
|
|
'DNT': '1',
|
|
'Sec-GPC': '1'
|
|
};
|
|
}
|
|
|
|
|
|
|
|
async _makeRequest(url, isJsonExpected = false) {
|
|
// Removed unused timerLabel variable (profiling optional)
|
|
|
|
// For JSON requests, we still cache normally since they're small
|
|
if (isJsonExpected) {
|
|
const cacheSubDir = 'showbox_generic';
|
|
const urlHash = crypto.createHash('md5').update(url).digest('hex');
|
|
const cacheKey = `${urlHash}.json`;
|
|
|
|
const cachedData = await getFromCache(cacheKey, cacheSubDir);
|
|
if (cachedData && typeof cachedData === 'object') {
|
|
return cachedData;
|
|
}
|
|
}
|
|
// For HTML requests, we don't cache the raw HTML anymore - it's too large and wasteful
|
|
|
|
// Always make direct requests
|
|
const requestUrl = url;
|
|
|
|
// Removed console.time call (timerLabel removed)
|
|
|
|
// Get the cookie with region preference if site requires it
|
|
let cookieValue = null;
|
|
if (url.includes('febbox.com')) {
|
|
if (this.regionPreference) {
|
|
console.log(`[ShowBoxScraper] Getting cookie with explicit region preference: ${this.regionPreference}`);
|
|
cookieValue = await getCookieForRequest(this.regionPreference, this.userCookie);
|
|
} else {
|
|
console.log(`[ShowBoxScraper] Getting cookie with default region (no preference specified)`);
|
|
cookieValue = await getCookieForRequest(null, this.userCookie);
|
|
}
|
|
}
|
|
|
|
const currentHeaders = { ...this.baseHeaders };
|
|
if (isJsonExpected) {
|
|
currentHeaders['Accept'] = 'application/json, text/javascript, */*; q=0.01';
|
|
currentHeaders['X-Requested-With'] = 'XMLHttpRequest';
|
|
currentHeaders['Sec-Fetch-Dest'] = 'empty';
|
|
currentHeaders['Sec-Fetch-Mode'] = 'cors';
|
|
delete currentHeaders['Upgrade-Insecure-Requests'];
|
|
}
|
|
|
|
if (cookieValue) {
|
|
currentHeaders['Cookie'] = `ui=${cookieValue}`;
|
|
}
|
|
|
|
try {
|
|
const response = await axios.get(requestUrl, {
|
|
headers: currentHeaders,
|
|
timeout: 30000
|
|
});
|
|
const responseData = response.data;
|
|
|
|
// Only cache JSON responses, not HTML (HTML is too large and wasteful)
|
|
if (responseData && isJsonExpected) {
|
|
const cacheSubDir = 'showbox_generic';
|
|
const urlHash = crypto.createHash('md5').update(url).digest('hex');
|
|
const cacheKey = `${urlHash}.json`;
|
|
await saveToCache(cacheKey, responseData, cacheSubDir);
|
|
}
|
|
// timerLabel removed; profiling disabled
|
|
return responseData;
|
|
} catch (error) {
|
|
const errorMessage = error.response ? `${error.message} (Status: ${error.response.status})` : error.message;
|
|
console.log(`ShowBoxScraper: Request failed for ${url}: ${errorMessage}`);
|
|
// timerLabel removed; profiling disabled
|
|
return null;
|
|
}
|
|
}
|
|
|
|
extractContentIdAndType(url, htmlContent) {
|
|
let contentId = null;
|
|
let contentTypeVal = null;
|
|
let title = "Unknown Title";
|
|
let sourceOfId = "unknown";
|
|
|
|
const urlMatchDetail = url.match(/\/(movie|tv)\/detail\/(\d+)(?:-([a-zA-Z0-9-]+))?/);
|
|
if (urlMatchDetail) {
|
|
const contentTypeStr = urlMatchDetail[1];
|
|
contentId = urlMatchDetail[2];
|
|
contentTypeVal = contentTypeStr === 'movie' ? '1' : '2';
|
|
const slugTitle = urlMatchDetail[3];
|
|
if (slugTitle) {
|
|
title = slugTitle.replace(/-/g, ' ').split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
|
}
|
|
sourceOfId = "url_direct";
|
|
}
|
|
|
|
if (htmlContent) {
|
|
const $ = cheerio.load(htmlContent);
|
|
let extractedHtmlTitle = null;
|
|
const titleElementH2 = $('h2.heading-name a');
|
|
if (titleElementH2.length) {
|
|
extractedHtmlTitle = titleElementH2.text().trim();
|
|
} else {
|
|
const titleElementH1 = $('h1.heading-name, h1.title');
|
|
if (titleElementH1.length) {
|
|
extractedHtmlTitle = titleElementH1.text().trim();
|
|
} else {
|
|
const titleElementMeta = $('meta[property="og:title"]');
|
|
if (titleElementMeta.length) {
|
|
extractedHtmlTitle = titleElementMeta.attr('content')?.trim();
|
|
} else {
|
|
const titleElementPlain = $('title, .movie-title, .tv-title');
|
|
if (titleElementPlain.length) {
|
|
extractedHtmlTitle = titleElementPlain.first().text().trim();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (extractedHtmlTitle) {
|
|
title = extractedHtmlTitle;
|
|
if (title.includes(" - ShowBox")) title = title.split(" - ShowBox")[0].trim();
|
|
if (title.includes("| ShowBox")) title = title.split("| ShowBox")[0].trim();
|
|
}
|
|
|
|
if (!contentId || !contentTypeVal) {
|
|
const headingLinkSelector = 'h2.heading-name a[href*="/detail/"], h1.heading-name a[href*="/detail/"]';
|
|
const headingLink = $(headingLinkSelector).first();
|
|
if (headingLink.length) {
|
|
const href = headingLink.attr('href');
|
|
if (href) {
|
|
const idTypeMatchHtml = href.match(/\/(movie|tv)\/detail\/(\d+)/);
|
|
if (idTypeMatchHtml) {
|
|
contentId = idTypeMatchHtml[2];
|
|
contentTypeVal = idTypeMatchHtml[1] === 'movie' ? '1' : '2';
|
|
sourceOfId = "html_heading_link";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!contentId) {
|
|
const shareDiv = $('div.sharethis-inline-share-buttons');
|
|
if (shareDiv.length) {
|
|
let linkElements = shareDiv.find('a[href*="/movie/detail/"], a[href*="/tv/detail/"]');
|
|
const dataUrlOnDiv = shareDiv.attr('data-url');
|
|
if (linkElements.length === 0 && dataUrlOnDiv) {
|
|
const dummyLinkSoup = cheerio.load(`<a href="${dataUrlOnDiv}"></a>`);
|
|
if (dummyLinkSoup('a').length) linkElements = dummyLinkSoup('a');
|
|
}
|
|
|
|
linkElements.each((i, el) => {
|
|
const href = $(el).attr('href');
|
|
if (href) {
|
|
const idTypeMatchShare = href.match(/\/(movie|tv)\/detail\/(\d+)/);
|
|
if (idTypeMatchShare) {
|
|
contentId = idTypeMatchShare[2];
|
|
contentTypeVal = idTypeMatchShare[1] === 'movie' ? '1' : '2';
|
|
sourceOfId = "html_share_div";
|
|
return false; // break loop
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (contentId && contentTypeVal) {
|
|
return { "id": contentId, "type": contentTypeVal, "title": title, "source": sourceOfId };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async extractFebboxShareLinks(showboxUrl) {
|
|
const timerLabel = `extractFebboxShareLinks_total_${showboxUrl.replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
console.log(`ShowBoxScraper: Attempting to extract FebBox share link from: ${showboxUrl}`);
|
|
console.time(timerLabel);
|
|
|
|
try {
|
|
let htmlContent = null;
|
|
let contentInfo = this.extractContentIdAndType(showboxUrl, null);
|
|
|
|
if (!contentInfo || !contentInfo.id || !contentInfo.type) {
|
|
console.log("ShowBoxScraper: ID/Type not in URL, fetching HTML.");
|
|
htmlContent = await this._makeRequest(showboxUrl);
|
|
if (!htmlContent) {
|
|
console.log(`ShowBoxScraper: Failed to fetch HTML for ${showboxUrl}.`);
|
|
console.timeEnd(timerLabel);
|
|
return [];
|
|
}
|
|
contentInfo = this.extractContentIdAndType(showboxUrl, htmlContent);
|
|
}
|
|
|
|
if (!contentInfo || !contentInfo.id || !contentInfo.type) {
|
|
console.log(`ShowBoxScraper: Could not determine content ID/type for ${showboxUrl}.`);
|
|
// console.timeEnd removed (timerLabel eliminated)
|
|
return [];
|
|
}
|
|
|
|
const { id: contentId, type: contentType, title } = contentInfo;
|
|
|
|
if (htmlContent) {
|
|
const $ = cheerio.load(htmlContent);
|
|
let directFebboxLink = null;
|
|
|
|
$('a[href*="febbox.com/share/"]').each((i, elem) => {
|
|
const href = $(elem).attr('href');
|
|
if (href && href.includes("febbox.com/share/")) {
|
|
directFebboxLink = href;
|
|
return false;
|
|
}
|
|
});
|
|
|
|
if (!directFebboxLink) {
|
|
const scriptContents = $('script').map((i, el) => $(el).html()).get().join('\n');
|
|
const shareKeyMatch = scriptContents.match(/['"](https?:\/\/www\.febbox\.com\/share\/[a-zA-Z0-9-]+)['"]/);
|
|
if (shareKeyMatch && shareKeyMatch[1]) {
|
|
directFebboxLink = shareKeyMatch[1];
|
|
}
|
|
}
|
|
|
|
if (directFebboxLink) {
|
|
console.log(`ShowBoxScraper: Successfully fetched FebBox URL: ${directFebboxLink} from HTML`);
|
|
console.timeEnd(timerLabel);
|
|
return [{
|
|
"showbox_title": title,
|
|
"febbox_share_url": directFebboxLink,
|
|
"showbox_content_id": contentId,
|
|
"showbox_content_type": contentType
|
|
}];
|
|
}
|
|
}
|
|
|
|
// API call if direct parsing didn't work
|
|
console.log(`ShowBoxScraper: Making API call for ${title} (ID: ${contentId}, Type: ${contentType})`);
|
|
const shareApiUrl = `https://www.showbox.media/index/share_link?id=${contentId}&type=${contentType}`;
|
|
const apiTimerLabel = `extractFebboxShareLinks_apiCall_${contentId}`;
|
|
console.time(apiTimerLabel);
|
|
const apiResponseStr = await this._makeRequest(shareApiUrl, true);
|
|
console.timeEnd(apiTimerLabel);
|
|
|
|
if (!apiResponseStr) {
|
|
console.log(`ShowBoxScraper: Failed to get response from ShowBox share_link API`);
|
|
// console.timeEnd removed (timerLabel eliminated)
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const apiResponseJson = (typeof apiResponseStr === 'string') ? JSON.parse(apiResponseStr) : apiResponseStr;
|
|
if (apiResponseJson.code === 1 && apiResponseJson.data && apiResponseJson.data.link) {
|
|
const febboxShareUrl = apiResponseJson.data.link;
|
|
console.log(`ShowBoxScraper: Successfully fetched FebBox URL: ${febboxShareUrl} from API`);
|
|
// timerLabel removed; profiling disabled
|
|
return [{
|
|
"showbox_title": title,
|
|
"febbox_share_url": febboxShareUrl,
|
|
"showbox_content_id": contentId,
|
|
"showbox_content_type": contentType
|
|
}];
|
|
} else {
|
|
console.log(`ShowBoxScraper: ShowBox share_link API did not succeed for '${title}'.`);
|
|
// timerLabel removed; profiling disabled
|
|
return [];
|
|
}
|
|
} catch (e) {
|
|
console.log(`ShowBoxScraper: Error decoding JSON from ShowBox share_link API: ${e.message}`);
|
|
// timerLabel removed; profiling disabled
|
|
return [];
|
|
}
|
|
} catch (error) {
|
|
// Catch any unexpected errors from the outer logic
|
|
console.error(`ShowBoxScraper: Unexpected error in extractFebboxShareLinks for ${showboxUrl}: ${error.message}`);
|
|
// console.timeEnd removed (timerLabel eliminated)
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Function to extract FIDs from FebBox share page
|
|
// MODIFICATION: Accept scraperApiKey -> MODIFICATION: Remove scraperApiKey
|
|
const extractFidsFromFebboxPage = async (febboxUrl, regionPreference = null, userCookie = null) => {
|
|
// Removed timerLabel profiling label from extractFidsFromFebboxPage
|
|
let directSources = []; // Initialize directSources
|
|
const cacheSubDirParsed = 'febbox_parsed_page'; // Only cache parsed data
|
|
const urlHash = crypto.createHash('md5').update(febboxUrl).digest('hex');
|
|
const cacheKeyParsed = `${urlHash}.json`; // Parsed data will be JSON
|
|
|
|
// Check for cached parsed data first
|
|
const cachedParsedData = await getFromCache(cacheKeyParsed, cacheSubDirParsed);
|
|
if (cachedParsedData && typeof cachedParsedData === 'object') { // Ensure it's an object
|
|
// console.log(` CACHE HIT for parsed FebBox page data: ${febboxUrl}`);
|
|
// profiling disabled (timerLabel removed)
|
|
return cachedParsedData; // Return { fids, shareKey, directSources }
|
|
}
|
|
// console.log(` CACHE MISS for parsed FebBox page data: ${febboxUrl}`);
|
|
|
|
// Fetch HTML content directly without caching it
|
|
let contentHtml = null;
|
|
const cookieForRequest = await getCookieForRequest(regionPreference, userCookie); // Pass region preference and user cookie
|
|
const baseHeaders = { 'Cookie': `ui=${cookieForRequest}` };
|
|
const fetchTimerLabel = `extractFidsFromFebboxPage_fetch_${febboxUrl.replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
|
|
// MODIFICATION: Removed ScraperAPI conditional logic
|
|
// const useScraperApi = process.env.USE_SCRAPER_API === 'true';
|
|
// const scraperApiKey = process.env.SCRAPER_API_KEY_VALUE;
|
|
|
|
const finalGetUrl = febboxUrl;
|
|
const axiosConfig = { headers: baseHeaders, timeout: 20000 };
|
|
|
|
// if (useScraperApi && scraperApiKey) {
|
|
// finalGetUrl = SCRAPER_API_BASE_URL; // Use defined base URL
|
|
// axiosConfig.params = { api_key: scraperApiKey, url: febboxUrl, keep_headers: 'true' };
|
|
// console.log(`Fetching FebBox page content from URL: ${febboxUrl} via ScraperAPI`);
|
|
// } else {
|
|
// console.log(`Fetching FebBox page content from URL: ${febboxUrl} directly`);
|
|
// }
|
|
console.log(`Fetching FebBox page content from URL: ${febboxUrl} directly`);
|
|
|
|
try {
|
|
// console.time(fetchTimerLabel);
|
|
const response = await axios.get(finalGetUrl, axiosConfig);
|
|
// console.timeEnd(fetchTimerLabel);
|
|
contentHtml = response.data;
|
|
// Note: No longer caching the raw HTML content
|
|
} catch (error) {
|
|
console.log(`Failed to fetch FebBox page: ${error.message}`);
|
|
if (fetchTimerLabel) console.timeEnd(fetchTimerLabel); // Ensure timer ends on error if started
|
|
// console.timeEnd(timerLabel);
|
|
return { fids: [], shareKey: null, directSources: [] };
|
|
}
|
|
|
|
let shareKey = null;
|
|
// Update regex to include hyphens in share key pattern
|
|
const matchShareKeyUrl = febboxUrl.match(/\/share\/([a-zA-Z0-9-]+)/);
|
|
if (matchShareKeyUrl) {
|
|
shareKey = matchShareKeyUrl[1];
|
|
} else if (contentHtml) {
|
|
// Update regex to include hyphens in share key pattern
|
|
const matchShareKeyHtml = contentHtml.match(/(?:var share_key\s*=|share_key:\s*|shareid=)"?([a-zA-Z0-9-]+)"?/);
|
|
if (matchShareKeyHtml) {
|
|
shareKey = matchShareKeyHtml[1];
|
|
}
|
|
}
|
|
|
|
if (!shareKey) {
|
|
console.log(`Warning: Could not extract share_key from ${febboxUrl}`);
|
|
// console.timeEnd(timerLabel);
|
|
return { fids: [], shareKey: null };
|
|
}
|
|
|
|
// Extract FIDs from the content HTML
|
|
const $ = cheerio.load(contentHtml);
|
|
const videoFidsFound = [];
|
|
|
|
// Direct player source check
|
|
const playerSetupMatch = contentHtml.match(/jwplayer\("[a-zA-Z0-9_-]+"\)\.setup/);
|
|
if (playerSetupMatch) {
|
|
const sourcesMatchDirect = contentHtml.match(/var sources = (.*?);\s*/s);
|
|
if (sourcesMatchDirect) {
|
|
try {
|
|
const sourcesJsArrayString = sourcesMatchDirect[1];
|
|
const sourcesData = JSON.parse(sourcesJsArrayString);
|
|
directSources = sourcesData.map(source => ({
|
|
label: String(source.label || 'Default'),
|
|
url: String(source.file)
|
|
})).filter(source => !!source.url);
|
|
return {
|
|
fids: [],
|
|
shareKey,
|
|
directSources
|
|
};
|
|
} catch (e) {
|
|
console.log(`Error decoding direct jwplayer sources: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// File list check
|
|
const fileElements = $('div.file');
|
|
if (fileElements.length === 0 && !(directSources && directSources.length > 0)) { // Check if directSources also not found
|
|
console.log(`No files or direct sources found on FebBox page: ${febboxUrl}`);
|
|
// console.timeEnd(timerLabel);
|
|
return { fids: [], shareKey, directSources: [] }; // Return empty directSources as well
|
|
}
|
|
|
|
fileElements.each((index, element) => {
|
|
const feEl = $(element);
|
|
const dataId = feEl.attr('data-id');
|
|
if (!dataId || !/^\d+$/.test(dataId) || feEl.hasClass('open_dir')) {
|
|
return; // Skip folders or invalid IDs
|
|
}
|
|
videoFidsFound.push(dataId);
|
|
});
|
|
|
|
// At the end of the function, before returning, save the parsed data
|
|
const parsedResult = { fids: [...new Set(videoFidsFound)], shareKey, directSources };
|
|
await saveToCache(cacheKeyParsed, parsedResult, cacheSubDirParsed);
|
|
// console.log(` SAVED PARSED FebBox page data to cache: ${febboxUrl}`);
|
|
// profiling disabled (timerLabel removed)
|
|
return parsedResult;
|
|
};
|
|
|
|
// Function to convert IMDb ID to TMDB ID using TMDB API
|
|
// MODIFICATION: Accept expectedType parameter to prioritize correct content type
|
|
const convertImdbToTmdb = async (imdbId, regionPreference = null, expectedType = null) => {
|
|
console.time(`convertImdbToTmdb_total_${imdbId}`);
|
|
if (!imdbId || !imdbId.startsWith('tt')) {
|
|
console.log(' Invalid IMDb ID format provided for conversion.', imdbId);
|
|
console.timeEnd(`convertImdbToTmdb_total_${imdbId}`);
|
|
return null;
|
|
}
|
|
console.log(` Attempting to convert IMDb ID: ${imdbId} to TMDB ID${expectedType ? ` (expected type: ${expectedType})` : ''}.`);
|
|
|
|
const cacheSubDir = 'tmdb_external_id';
|
|
// Include expected type in cache key to avoid conflicts between movie/tv requests
|
|
const cacheKey = `imdb-${imdbId}${expectedType ? `-${expectedType}` : ''}.json`;
|
|
const cachedData = await getFromCache(cacheKey, cacheSubDir);
|
|
|
|
if (cachedData) {
|
|
console.log(` IMDb to TMDB conversion found in CACHE for ${imdbId}:`, cachedData);
|
|
// Ensure cached data has the expected structure
|
|
if (cachedData.tmdbId && cachedData.tmdbType) {
|
|
console.timeEnd(`convertImdbToTmdb_total_${imdbId}`);
|
|
return cachedData;
|
|
}
|
|
console.log(' Cached data for IMDb conversion is malformed. Fetching fresh.');
|
|
}
|
|
|
|
const TMDB_API_KEY = obtainTmdbKey();
|
|
const findApiUrl = `${TMDB_BASE_URL}/find/${imdbId}?api_key=${TMDB_API_KEY}&external_source=imdb_id`;
|
|
console.log(` Fetching from TMDB find API: ${findApiUrl}`);
|
|
console.time(`convertImdbToTmdb_apiCall_${imdbId}`);
|
|
|
|
try {
|
|
const response = await axios.get(findApiUrl, { timeout: 10000 });
|
|
console.timeEnd(`convertImdbToTmdb_apiCall_${imdbId}`);
|
|
const findResults = response.data;
|
|
|
|
if (findResults) {
|
|
let result = null;
|
|
|
|
// Context-aware prioritization based on expected type
|
|
if (expectedType === 'tv' || expectedType === 'series') {
|
|
// For series requests, prioritize TV results
|
|
if (findResults.tv_results && findResults.tv_results.length > 0) {
|
|
result = { tmdbId: String(findResults.tv_results[0].id), tmdbType: 'tv', title: findResults.tv_results[0].name || findResults.tv_results[0].original_name };
|
|
console.log(` Prioritized TV result for series request: ${result.title}`);
|
|
} else if (findResults.movie_results && findResults.movie_results.length > 0) {
|
|
result = { tmdbId: String(findResults.movie_results[0].id), tmdbType: 'movie', title: findResults.movie_results[0].title || findResults.movie_results[0].original_title };
|
|
console.log(` Fallback to movie result for series request: ${result.title}`);
|
|
}
|
|
} else if (expectedType === 'movie') {
|
|
// For movie requests, prioritize movie results
|
|
if (findResults.movie_results && findResults.movie_results.length > 0) {
|
|
result = { tmdbId: String(findResults.movie_results[0].id), tmdbType: 'movie', title: findResults.movie_results[0].title || findResults.movie_results[0].original_title };
|
|
console.log(` Prioritized movie result for movie request: ${result.title}`);
|
|
} else if (findResults.tv_results && findResults.tv_results.length > 0) {
|
|
result = { tmdbId: String(findResults.tv_results[0].id), tmdbType: 'tv', title: findResults.tv_results[0].name || findResults.tv_results[0].original_name };
|
|
console.log(` Fallback to TV result for movie request: ${result.title}`);
|
|
}
|
|
} else {
|
|
// Default behavior: prioritize movie results, then tv results (backward compatibility)
|
|
if (findResults.movie_results && findResults.movie_results.length > 0) {
|
|
result = { tmdbId: String(findResults.movie_results[0].id), tmdbType: 'movie', title: findResults.movie_results[0].title || findResults.movie_results[0].original_title };
|
|
} else if (findResults.tv_results && findResults.tv_results.length > 0) {
|
|
result = { tmdbId: String(findResults.tv_results[0].id), tmdbType: 'tv', title: findResults.tv_results[0].name || findResults.tv_results[0].original_name };
|
|
}
|
|
}
|
|
|
|
if (findResults.person_results && findResults.person_results.length > 0 && !result) {
|
|
// Could handle other types if necessary, e.g. person, but for streams, movie/tv are key
|
|
console.log(` IMDb ID ${imdbId} resolved to a person, not a movie or TV show on TMDB.`);
|
|
} else if (!result) {
|
|
console.log(` No movie or TV results found on TMDB for IMDb ID ${imdbId}. Response:`, JSON.stringify(findResults).substring(0, 200));
|
|
}
|
|
|
|
if (result && result.tmdbId && result.tmdbType) {
|
|
console.log(` Successfully converted IMDb ID ${imdbId} to TMDB ${result.tmdbType} ID ${result.tmdbId} (${result.title})`);
|
|
await saveToCache(cacheKey, result, cacheSubDir);
|
|
console.timeEnd(`convertImdbToTmdb_total_${imdbId}`);
|
|
return result;
|
|
} else {
|
|
console.log(` Could not convert IMDb ID ${imdbId} to a usable TMDB movie/tv ID.`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (console.timeEnd && typeof console.timeEnd === 'function') console.timeEnd(`convertImdbToTmdb_apiCall_${imdbId}`); // Ensure timer ends on error
|
|
const errorMessage = error.response ? `${error.message} (Status: ${error.response.status})` : error.message;
|
|
console.log(` Error during TMDB find API call for IMDb ID ${imdbId}: ${errorMessage}`);
|
|
}
|
|
console.timeEnd(`convertImdbToTmdb_total_${imdbId}`);
|
|
return null;
|
|
};
|
|
|
|
// Public API: fetch streams by TMDB reference
|
|
const getStreamsFromTmdbId = async (tmdbType, tmdbId, seasonNum = null, episodeNum = null, regionPreference = null, userCookie = null) => {
|
|
const mainTimerLabel = `getStreamsFromTmdbId_total_${tmdbType}_${tmdbId}` + (seasonNum ? `_s${seasonNum}` : '') + (episodeNum ? `_e${episodeNum}` : '');
|
|
console.time(mainTimerLabel);
|
|
console.log(`Getting streams for TMDB ${tmdbType}/${tmdbId}${seasonNum !== null ? `, Season ${seasonNum}` : ''}${episodeNum !== null ? `, Episode ${episodeNum}` : ''}`);
|
|
|
|
// PRIMARY METHOD: Try PStream API first (if enabled)
|
|
|
|
try {
|
|
// Get TMDB data to extract IMDB ID
|
|
const tmdbData = await getTmdbDataForPStream(tmdbType, tmdbId);
|
|
if (tmdbData && tmdbData.imdbId) {
|
|
console.log(`[PStream Primary] Attempting PStream API for IMDB ID: ${tmdbData.imdbId}`);
|
|
|
|
const pstreamStreams = await getStreamsFromPStreamAPI(
|
|
tmdbData.imdbId,
|
|
tmdbType,
|
|
seasonNum,
|
|
episodeNum,
|
|
regionPreference,
|
|
userCookie
|
|
);
|
|
|
|
if (pstreamStreams && pstreamStreams.length > 0) {
|
|
console.log(`[PStream Primary] SUCCESS: Found ${pstreamStreams.length} streams from PStream API`);
|
|
|
|
// Fetch sizes for PStream streams
|
|
if (pstreamStreams.length > 0) {
|
|
const sizePromises = pstreamStreams.map(async (stream) => {
|
|
stream.size = await fetchStreamSize(stream.url);
|
|
return stream;
|
|
});
|
|
await Promise.all(sizePromises); // removed unused streamsWithSizes var
|
|
}
|
|
|
|
const sortedStreams = sortStreamsByQuality(pstreamStreams);
|
|
console.timeEnd(mainTimerLabel);
|
|
return sortedStreams;
|
|
} else {
|
|
console.log(`[PStream Primary] No streams found from PStream API, falling back to FebBox`);
|
|
}
|
|
} else {
|
|
console.log(`[PStream Primary] Could not get IMDB ID from TMDB data, falling back to FebBox`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`[PStream Primary] Error with PStream API: ${error.message}, falling back to FebBox`);
|
|
}
|
|
|
|
// FALLBACK METHOD: Use original FebBox/ShowBox logic
|
|
console.log(`[Fallback] Using FebBox/ShowBox method`);
|
|
const tmdbInfo = await getShowboxUrlFromTmdbInfo(tmdbType, tmdbId, regionPreference);
|
|
|
|
// Then, get the ShowBox URL from TMDB ID
|
|
if (!tmdbInfo || !tmdbInfo.showboxUrl) {
|
|
console.log(`Could not construct ShowBox URL for TMDB ${tmdbType}/${tmdbId}`);
|
|
console.timeEnd(mainTimerLabel);
|
|
return [];
|
|
}
|
|
const showboxUrl = tmdbInfo.showboxUrl;
|
|
const mediaYear = tmdbInfo.year; // Year from TMDB
|
|
|
|
// Check if we have cached FebBox share links to avoid redundant ShowBox requests
|
|
let febboxShareInfos = tmdbInfo.febboxShareInfos;
|
|
|
|
if (!febboxShareInfos) {
|
|
// Only make ShowBox request if FebBox links are not cached
|
|
console.log(`No cached FebBox links found, extracting from ShowBox: ${showboxUrl}`);
|
|
const showboxScraper = new ShowBoxScraper(regionPreference, userCookie);
|
|
febboxShareInfos = await showboxScraper.extractFebboxShareLinks(showboxUrl);
|
|
|
|
if (!febboxShareInfos || febboxShareInfos.length === 0) {
|
|
console.log(`No FebBox share links found for ${showboxUrl}`);
|
|
console.timeEnd(mainTimerLabel);
|
|
return [];
|
|
}
|
|
|
|
// Update cache with FebBox share links to avoid future ShowBox requests
|
|
if (process.env.DISABLE_CACHE !== 'true') {
|
|
const updatedTmdbInfo = { ...tmdbInfo, febboxShareInfos };
|
|
const urlCacheSubDir = 'showbox_final_url';
|
|
const urlCacheKey = `v3_${tmdbType}_${tmdbId}.json`;
|
|
await saveToCache(urlCacheKey, updatedTmdbInfo, urlCacheSubDir);
|
|
console.log(`[Final URL Cache] UPDATED with FebBox links for ${tmdbType}/${tmdbId}`);
|
|
}
|
|
} else {
|
|
console.log(`[Final URL Cache] Using cached FebBox links, skipping ShowBox request for ${showboxUrl}`);
|
|
}
|
|
|
|
// MODIFIED: Process FebBox share links in parallel
|
|
const streamPromises = febboxShareInfos.map(async (shareInfo) => {
|
|
const streamsFromThisShareInfo = [];
|
|
try {
|
|
const febboxUrl = shareInfo.febbox_share_url;
|
|
let baseStreamTitle = shareInfo.showbox_title || "Unknown Title";
|
|
if (tmdbType === 'movie' && mediaYear) {
|
|
baseStreamTitle = `${baseStreamTitle} (${mediaYear})`;
|
|
}
|
|
|
|
console.log(`Processing FebBox URL: ${febboxUrl} (${baseStreamTitle})`);
|
|
|
|
if (tmdbType === 'tv' && seasonNum !== null) {
|
|
// Call refactored processShowWithSeasonsEpisodes (which now returns streams)
|
|
const tvStreams = await processShowWithSeasonsEpisodes(febboxUrl, baseStreamTitle, seasonNum, episodeNum, true, regionPreference, userCookie);
|
|
streamsFromThisShareInfo.push(...tvStreams);
|
|
} else {
|
|
// Handle movies or TV shows without season/episode specified
|
|
const { fids, shareKey, directSources } = await extractFidsFromFebboxPage(febboxUrl, regionPreference, userCookie);
|
|
|
|
if (directSources && directSources.length > 0) {
|
|
for (const source of directSources) {
|
|
const streamTitle = `${baseStreamTitle} - ${source.label}`;
|
|
let key5FromDirectSource = null;
|
|
try {
|
|
const urlParams = new URLSearchParams(new URL(source.url).search);
|
|
if (urlParams.has('KEY5')) {
|
|
key5FromDirectSource = urlParams.get('KEY5');
|
|
}
|
|
} catch (e) { /* ignore if URL parsing fails */ }
|
|
streamsFromThisShareInfo.push({
|
|
name: streamTitle,
|
|
title: streamTitle,
|
|
url: source.url,
|
|
quality: parseQualityFromLabel(source.label),
|
|
provider: 'Febbox Direct',
|
|
headers: {}
|
|
});
|
|
}
|
|
// If direct sources are found, original code used 'continue',
|
|
// effectively skipping FID processing for this shareInfo.
|
|
// This behavior is maintained as FID processing is in the 'else if' block.
|
|
} else if (fids.length > 0 && shareKey) { // Only process FIDs if no directSources were found
|
|
const fidPromises = fids.map(fid => fetchSourcesForSingleFid(fid, shareKey, regionPreference, userCookie));
|
|
const fidSourcesArray = await Promise.all(fidPromises);
|
|
|
|
for (const sources of fidSourcesArray) {
|
|
if (!sources || !Array.isArray(sources)) {
|
|
console.log(` Warning: Invalid sources data received: ${typeof sources}`);
|
|
continue;
|
|
}
|
|
|
|
for (const source of sources) {
|
|
if (!source || !source.url || !source.label) {
|
|
console.log(` Warning: Invalid source object: ${JSON.stringify(source)}`);
|
|
continue;
|
|
}
|
|
|
|
const streamTitle = `${baseStreamTitle} - ${source.label}`;
|
|
streamsFromThisShareInfo.push({
|
|
name: streamTitle,
|
|
title: streamTitle,
|
|
url: source.url,
|
|
quality: parseQualityFromLabel(source.label),
|
|
provider: 'Febbox FID',
|
|
headers: {}
|
|
});
|
|
}
|
|
}
|
|
} else if (!directSources || directSources.length === 0) {
|
|
console.log(`No FIDs or share key found, and no direct sources for ${febboxUrl}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error processing share info for ${shareInfo.febbox_share_url}: ${error.message}`);
|
|
}
|
|
return streamsFromThisShareInfo;
|
|
});
|
|
|
|
const nestedStreams = await Promise.all(streamPromises);
|
|
const allStreams = nestedStreams.flat();
|
|
|
|
// Fetch sizes for all streams concurrently
|
|
if (allStreams.length > 0) {
|
|
console.time(`getStreamsFromTmdbId_fetchStreamSizes_${tmdbType}_${tmdbId}`);
|
|
const sizePromises = allStreams.map(async (stream) => {
|
|
stream.size = await fetchStreamSize(stream.url);
|
|
return stream;
|
|
});
|
|
await Promise.all(sizePromises); // removed unused streamsWithSizes var
|
|
console.timeEnd(`getStreamsFromTmdbId_fetchStreamSizes_${tmdbType}_${tmdbId}`);
|
|
}
|
|
|
|
// Sort streams by quality before returning
|
|
const sortedStreams = sortStreamsByQuality(allStreams);
|
|
|
|
// Filter out 360p and 480p streams
|
|
const streamsToShowBoxFiltered = sortedStreams.filter(stream => {
|
|
const quality = stream.quality ? String(stream.quality).toLowerCase() : '';
|
|
if (quality === '360p' || quality === '480p') {
|
|
console.log(` Filtering out potential ShowBox low-quality stream: ${stream.title} (${stream.quality})`);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Apply size limit if not using a personal cookie
|
|
let finalFilteredStreams = streamsToShowBoxFiltered;
|
|
if (!userCookie && !global.currentRequestUserCookie) {
|
|
console.log('[SizeLimit] No personal cookie detected. Applying 9GB size limit to ShowBox streams.');
|
|
const NINE_GB_IN_BYTES = 9 * 1024 * 1024 * 1024;
|
|
finalFilteredStreams = streamsToShowBoxFiltered.filter(stream => {
|
|
const sizeInBytes = parseSizeToBytes(stream.size);
|
|
if (sizeInBytes >= NINE_GB_IN_BYTES) {
|
|
console.log(`[SizeLimit] Filtering out ShowBox stream due to size (${stream.size || 'Unknown size'}): ${stream.title}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
if (finalFilteredStreams.length > 0) {
|
|
console.log(`Found ${finalFilteredStreams.length} streams (sorted, ShowBox-low-quality-filtered, and size-limited if applicable):`);
|
|
finalFilteredStreams.slice(0, 5).forEach((stream, i) => {
|
|
console.log(` ${i + 1}. ${stream.quality} (${stream.size || 'Unknown size'}) [${(stream.codecs || []).join(', ') || 'No codec info'}]: ${stream.title}`);
|
|
});
|
|
if (finalFilteredStreams.length > 5) {
|
|
console.log(` ... and ${finalFilteredStreams.length - 5} more streams`);
|
|
}
|
|
}
|
|
console.timeEnd(mainTimerLabel);
|
|
return finalFilteredStreams;
|
|
};
|
|
|
|
// Function to handle TV shows with seasons and episodes
|
|
// MODIFICATION: Accept scraperApiKey -> MODIFICATION: Remove scraperApiKey
|
|
// New parameter: resolveFids, defaults to true. If false, skips fetching sources for FIDs.
|
|
const processShowWithSeasonsEpisodes = async (febboxUrl, showboxTitle, seasonNum, episodeNum, resolveFids = true, regionPreference = null, userCookie = null, retryAttempt = 0) => {
|
|
const processTimerLabel = `processShowWithSeasonsEpisodes_total_s${seasonNum}` + (episodeNum ? `_e${episodeNum}` : '_all') + (resolveFids ? '_resolve' : '_noresolve') + (retryAttempt > 0 ? `_retry${retryAttempt}` : '');
|
|
console.time(processTimerLabel);
|
|
console.log(`Processing TV Show: ${showboxTitle}, Season: ${seasonNum}, Episode: ${episodeNum !== null ? episodeNum : 'all'}${resolveFids ? '' : ' (FIDs not resolved)'}${retryAttempt > 0 ? ` (Retry attempt ${retryAttempt})` : ''}`);
|
|
|
|
const streamsForThisCall = []; // Initialize local array to store streams for this call
|
|
let selectedEpisode = null; // Ensure selectedEpisode is declared here
|
|
|
|
// Cache for the main FebBox page
|
|
const cacheSubDirMain = 'febbox_page_html';
|
|
const urlHash = crypto.createHash('md5').update(febboxUrl).digest('hex');
|
|
const cacheKeyMain = `${urlHash}.html`;
|
|
|
|
// Try to get the main page from cache first (skip cache on retry)
|
|
let contentHtml = retryAttempt > 0 ? null : await getFromCache(cacheKeyMain, cacheSubDirMain);
|
|
|
|
if (!contentHtml) {
|
|
// If not cached, fetch the HTML content
|
|
const fetchMainPageTimer = `processShowWithSeasonsEpisodes_fetchMainPage_s${seasonNum}`;
|
|
console.time(fetchMainPageTimer);
|
|
|
|
const cookieForRequest = await getCookieForRequest(regionPreference, userCookie);
|
|
|
|
const finalFebboxUrl = febboxUrl;
|
|
const axiosConfigMainPage = {
|
|
headers: { 'Cookie': `ui=${cookieForRequest}` },
|
|
timeout: 20000
|
|
};
|
|
|
|
console.log(`Fetching main FebBox page ${febboxUrl} directly${retryAttempt > 0 ? ' (forced refresh for missing season)' : ''}`);
|
|
|
|
try {
|
|
const response = await axios.get(finalFebboxUrl, axiosConfigMainPage);
|
|
contentHtml = response.data;
|
|
if (typeof contentHtml === 'string' && contentHtml.length > 0) {
|
|
await saveToCache(cacheKeyMain, contentHtml, cacheSubDirMain);
|
|
}
|
|
console.timeEnd(fetchMainPageTimer);
|
|
} catch (error) {
|
|
console.log(`Failed to fetch HTML content from ${febboxUrl}: ${error.message}`);
|
|
console.timeEnd(fetchMainPageTimer);
|
|
console.timeEnd(processTimerLabel);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!contentHtml) {
|
|
console.log(`No HTML content available for ${febboxUrl}`);
|
|
console.timeEnd(processTimerLabel);
|
|
return;
|
|
}
|
|
|
|
// Parse the HTML to find folders (seasons)
|
|
const $ = cheerio.load(contentHtml);
|
|
const shareKey = contentHtml.match(/(?:var share_key\s*=|share_key:\s*|shareid=)"?([a-zA-Z0-9-]+)"?/)?.[1];
|
|
|
|
if (!shareKey) {
|
|
console.log(`Could not extract share_key from ${febboxUrl}`);
|
|
console.timeEnd(processTimerLabel);
|
|
return;
|
|
}
|
|
|
|
const folders = [];
|
|
const fileElements = $('div.file.open_dir');
|
|
|
|
fileElements.each((index, element) => {
|
|
const feEl = $(element);
|
|
const dataId = feEl.attr('data-id');
|
|
if (!dataId || !/^\d+$/.test(dataId)) {
|
|
return; // Skip if data-id is missing or not a number
|
|
}
|
|
|
|
const folderNameEl = feEl.find('p.file_name');
|
|
const folderName = folderNameEl.length ? folderNameEl.text().trim() : feEl.attr('data-path') || `Folder_${dataId}`;
|
|
|
|
// Extract season number from folder name for more accurate matching
|
|
let extractedSeasonNum = null;
|
|
const folderNameLower = folderName.toLowerCase();
|
|
|
|
// Try more specific season number extraction patterns
|
|
const seasonPatterns = [
|
|
/season\s+(\d+)/i, // Season 1
|
|
/s(\d+)/i, // S1
|
|
/season(\d+)/i // Season1
|
|
];
|
|
|
|
for (const pattern of seasonPatterns) {
|
|
const match = folderNameLower.match(pattern);
|
|
if (match && match[1]) {
|
|
extractedSeasonNum = parseInt(match[1], 10);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If no specific pattern matched, look for any standalone numbers
|
|
if (extractedSeasonNum === null) {
|
|
const numMatches = folderNameLower.match(/\b(\d+)\b/g);
|
|
if (numMatches && numMatches.length === 1) { // Only if exactly one number is found
|
|
extractedSeasonNum = parseInt(numMatches[0], 10);
|
|
}
|
|
}
|
|
|
|
folders.push({
|
|
id: dataId,
|
|
name: folderName,
|
|
extractedSeasonNum: extractedSeasonNum
|
|
});
|
|
});
|
|
|
|
if (folders.length === 0) {
|
|
console.log(`No season folders found on ${febboxUrl}`);
|
|
// It might be directly files, so try the original logic as fallback
|
|
console.time(`processShowWithSeasonsEpisodes_fallbackDirect_s${seasonNum}`);
|
|
const { fids, directSources } = await extractFidsFromFebboxPage(febboxUrl, regionPreference, userCookie);
|
|
console.timeEnd(`processShowWithSeasonsEpisodes_fallbackDirect_s${seasonNum}`);
|
|
|
|
if (directSources && directSources.length > 0) {
|
|
for (const source of directSources) {
|
|
const streamTitle = `${showboxTitle} - ${source.label}`;
|
|
streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall
|
|
name: streamTitle,
|
|
title: streamTitle,
|
|
url: source.url,
|
|
quality: parseQualityFromLabel(source.label),
|
|
provider: 'Febbox Direct',
|
|
headers: {}
|
|
});
|
|
}
|
|
console.timeEnd(processTimerLabel);
|
|
return streamsForThisCall;
|
|
}
|
|
|
|
if (fids.length > 0) {
|
|
console.time(`processShowWithSeasonsEpisodes_fallbackFids_s${seasonNum}_concurrent`);
|
|
const fallbackFidPromises = fids.map(fid => fetchSourcesForSingleFid(fid, shareKey, regionPreference, userCookie));
|
|
const fallbackFidSourcesArray = await Promise.all(fallbackFidPromises);
|
|
console.timeEnd(`processShowWithSeasonsEpisodes_fallbackFids_s${seasonNum}_concurrent`);
|
|
|
|
for (const sources of fallbackFidSourcesArray) {
|
|
for (const source of sources) {
|
|
const streamTitle = `${showboxTitle} - ${source.label}`;
|
|
streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall
|
|
name: streamTitle,
|
|
title: streamTitle,
|
|
url: source.url,
|
|
quality: parseQualityFromLabel(source.label),
|
|
provider: 'Febbox FID',
|
|
headers: {}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
console.timeEnd(processTimerLabel);
|
|
return streamsForThisCall;
|
|
}
|
|
|
|
console.log(`Found ${folders.length} season folders:`, folders.map(f =>
|
|
`"${f.name}" (ID: ${f.id}, Extracted Season: ${f.extractedSeasonNum !== null ? f.extractedSeasonNum : 'None'})`
|
|
).join(', '));
|
|
|
|
// Find matching season folder
|
|
let selectedFolder = null;
|
|
|
|
// First, look for exact season number match using extracted season numbers
|
|
for (const folder of folders) {
|
|
if (folder.extractedSeasonNum === seasonNum) {
|
|
selectedFolder = folder;
|
|
console.log(`Found exact season match: "${folder.name}" with extracted season number ${folder.extractedSeasonNum}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If no exact match by extracted number, try the text pattern matches (legacy approach)
|
|
if (!selectedFolder) {
|
|
for (const folder of folders) {
|
|
const folderNameLower = folder.name.toLowerCase();
|
|
|
|
// More precise matching patterns to avoid partial matches
|
|
if (
|
|
folderNameLower === `season ${seasonNum}` ||
|
|
folderNameLower === `s${seasonNum}` ||
|
|
folderNameLower === `season${seasonNum}` ||
|
|
// Match with word boundaries to avoid Season 1 matching Season 10
|
|
folderNameLower.match(new RegExp(`\\bseason\\s+${seasonNum}\\b`)) ||
|
|
folderNameLower.match(new RegExp(`\\bs${seasonNum}\\b`))
|
|
) {
|
|
selectedFolder = folder;
|
|
console.log(`Found season match via text pattern: "${folder.name}"`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If still no match, sort folders by extracted season number and try index-based approach
|
|
if (!selectedFolder && seasonNum > 0 && seasonNum <= folders.length) {
|
|
// First try to sort by extracted season number (if available)
|
|
const sortedFolders = [...folders].sort((a, b) => {
|
|
// If both have extracted season numbers, compare them
|
|
if (a.extractedSeasonNum !== null && b.extractedSeasonNum !== null) {
|
|
return a.extractedSeasonNum - b.extractedSeasonNum;
|
|
}
|
|
// If only one has extracted season number, put it first
|
|
if (a.extractedSeasonNum !== null) return -1;
|
|
if (b.extractedSeasonNum !== null) return 1;
|
|
// Otherwise, keep original order
|
|
return 0;
|
|
});
|
|
|
|
// Log the sorted folders for debugging
|
|
console.log(`Sorted folders by season number:`, sortedFolders.map(f =>
|
|
`"${f.name}" (ID: ${f.id}, Extracted Season: ${f.extractedSeasonNum !== null ? f.extractedSeasonNum : 'None'})`
|
|
).join(', '));
|
|
|
|
// Check if any folder has an extracted season number matching the requested season
|
|
const exactExtractedMatch = sortedFolders.find(f => f.extractedSeasonNum === seasonNum);
|
|
if (exactExtractedMatch) {
|
|
selectedFolder = exactExtractedMatch;
|
|
console.log(`Found exact season match in sorted folders: "${selectedFolder.name}" with season ${selectedFolder.extractedSeasonNum}`);
|
|
}
|
|
// Otherwise, if we have folders with extracted season numbers, use them for mapping
|
|
else if (sortedFolders.some(f => f.extractedSeasonNum !== null)) {
|
|
// If we have some season numbers, try to find the appropriate folder
|
|
// This approach is better than using index because folders might be out of order
|
|
const validFolders = sortedFolders.filter(f => f.extractedSeasonNum !== null);
|
|
if (seasonNum <= validFolders.length) {
|
|
selectedFolder = validFolders[seasonNum - 1];
|
|
console.log(`Using sorted folder by extracted number index: "${selectedFolder.name}" at position ${seasonNum}`);
|
|
}
|
|
}
|
|
// Last resort: use index-based approach on original folder order
|
|
else {
|
|
selectedFolder = folders[seasonNum - 1];
|
|
console.log(`Using original folder order index: "${selectedFolder.name}" at position ${seasonNum}`);
|
|
}
|
|
}
|
|
|
|
if (!selectedFolder) {
|
|
// If season not found and this is the first attempt, try refreshing the cache
|
|
if (retryAttempt === 0) {
|
|
console.log(`Season ${seasonNum} not found in cached data. Attempting fresh fetch...`);
|
|
console.timeEnd(processTimerLabel);
|
|
// Recursive call with retry attempt to force fresh fetch
|
|
return await processShowWithSeasonsEpisodes(febboxUrl, showboxTitle, seasonNum, episodeNum, resolveFids, regionPreference, userCookie, 1);
|
|
} else {
|
|
console.log(`Could not find season ${seasonNum} folder in ${febboxUrl} even after fresh fetch. Aborting.`);
|
|
console.timeEnd(processTimerLabel);
|
|
return streamsForThisCall;
|
|
}
|
|
}
|
|
|
|
console.log(`Selected season folder: ${selectedFolder.name} (ID: ${selectedFolder.id})`);
|
|
|
|
// Cache for season folder content
|
|
const cacheSubDirFolderHtml = 'febbox_season_folders';
|
|
const cacheSubDirFolderParsed = 'febbox_parsed_season_folders';
|
|
const cacheKeyFolderHtml = `share-${shareKey}_folder-${selectedFolder.id}.html`;
|
|
const cacheKeyFolderParsed = `share-${shareKey}_folder-${selectedFolder.id}_parsed.json`;
|
|
|
|
let folderHtml = null;
|
|
const episodeDetails = []; // Declare episodeDetails here, initialized as an empty array
|
|
const episodeFids = []; // Declare episodeFids here, initialized as an empty array
|
|
|
|
const cachedParsedEpisodeList = await getFromCache(cacheKeyFolderParsed, cacheSubDirFolderParsed);
|
|
if (cachedParsedEpisodeList && Array.isArray(cachedParsedEpisodeList)) {
|
|
console.log(` CACHE HIT for parsed episode list: Season ${seasonNum}, Folder ${selectedFolder.id}`);
|
|
episodeDetails.push(...cachedParsedEpisodeList); // Populate if cache hit
|
|
} else {
|
|
// Parsed list not in cache, so we need to process HTML
|
|
// console.log(` CACHE MISS for parsed episode list: Season ${seasonNum}, Folder ${selectedFolder.id}`);
|
|
folderHtml = await getFromCache(cacheKeyFolderHtml, cacheSubDirFolderHtml); // Assign to the higher-scoped folderHtml
|
|
|
|
if (!folderHtml) {
|
|
const fetchFolderTimer = `processShowWithSeasonsEpisodes_fetchFolder_s${seasonNum}_id${selectedFolder.id}`;
|
|
console.time(fetchFolderTimer);
|
|
try {
|
|
const targetFolderListUrl = `${FEBBOX_FILE_SHARE_LIST_URL}?share_key=${shareKey}&parent_id=${selectedFolder.id}&is_html=1&pwd=`;
|
|
|
|
const cookieForRequestFolder = await getCookieForRequest(regionPreference, userCookie); // Simplified cookie call
|
|
const finalFolderUrl = targetFolderListUrl;
|
|
const axiosConfigFolder = {
|
|
headers: {
|
|
'Cookie': `ui=${cookieForRequestFolder}`,
|
|
'Referer': febboxUrl,
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
},
|
|
timeout: 20000
|
|
};
|
|
|
|
console.log(`Fetching FebBox folder ${targetFolderListUrl} directly`);
|
|
|
|
const folderResponse = await axios.get(finalFolderUrl, axiosConfigFolder);
|
|
console.log(` FebBox folder list response status: ${folderResponse.status}`);
|
|
console.log(` FebBox folder list response content-type: ${folderResponse.headers['content-type']}`);
|
|
// Log the beginning of the data to inspect its structure
|
|
const responseDataPreview = (typeof folderResponse.data === 'string')
|
|
? folderResponse.data.substring(0, 500)
|
|
: JSON.stringify(folderResponse.data).substring(0, 500);
|
|
console.log(` FebBox folder list response data (preview): ${responseDataPreview}`);
|
|
|
|
if (folderResponse.data && typeof folderResponse.data === 'object' && folderResponse.data.html) {
|
|
folderHtml = folderResponse.data.html;
|
|
console.log(` Successfully extracted HTML from FebBox folder list JSON response.`);
|
|
} else if (typeof folderResponse.data === 'string') {
|
|
folderHtml = folderResponse.data;
|
|
console.log(` Received direct HTML string from FebBox folder list response.`);
|
|
} else {
|
|
console.log(` Invalid or unexpected response format from FebBox folder API for ${selectedFolder.id}. Data: ${JSON.stringify(folderResponse.data)}`);
|
|
// folderHtml remains null
|
|
}
|
|
|
|
if (folderHtml && folderHtml.trim().length > 0) { // Also check if html is not just whitespace
|
|
await saveToCache(cacheKeyFolderHtml, folderHtml, cacheSubDirFolderHtml);
|
|
console.log(` Cached FebBox folder HTML for ${selectedFolder.id}`);
|
|
} else {
|
|
console.log(` Folder HTML from FebBox was empty or null for ${selectedFolder.id}. Not caching.`);
|
|
folderHtml = null; // Ensure it's explicitly null if empty
|
|
}
|
|
console.timeEnd(fetchFolderTimer);
|
|
} catch (error) {
|
|
console.error(` ERROR fetching FebBox folder content for ${selectedFolder.id}: ${error.message}`);
|
|
if (error.response) {
|
|
console.error(` FebBox Error Status: ${error.response.status}`);
|
|
console.error(` FebBox Error Data: ${JSON.stringify(error.response.data)}`);
|
|
}
|
|
// console.timeEnd(fetchFolderTimer); // fetchFolderTimer might not be defined if cache hit for HTML but miss for parsed
|
|
console.timeEnd(processTimerLabel); // End outer timer
|
|
return streamsForThisCall; // Return empty streams array if fetching folder content fails
|
|
}
|
|
}
|
|
}
|
|
|
|
// If episodeDetails is populated from cache, folderHtml might be null. That's okay.
|
|
// If episodeDetails is still empty, we must have folderHtml to parse.
|
|
if (episodeDetails.length === 0) { // Only proceed if we didn't get data from parsed cache
|
|
if (!folderHtml) { // If still no folderHtml (e.g. HTML cache miss and fetch fail), then error out
|
|
console.log(`No folder HTML content available (and no cached parsed list) for folder ${selectedFolder.id}`);
|
|
console.timeEnd(processTimerLabel);
|
|
return streamsForThisCall;
|
|
}
|
|
|
|
// Parse the folderHtml since we didn't have a cached parsed list
|
|
const $folder = cheerio.load(folderHtml);
|
|
$folder('div.file').each((index, element) => {
|
|
const feEl = $folder(element);
|
|
const dataId = feEl.attr('data-id');
|
|
if (!dataId || !/^\d+$/.test(dataId) || feEl.hasClass('open_dir')) {
|
|
return;
|
|
}
|
|
|
|
const fileNameEl = feEl.find('p.file_name');
|
|
const fileName = fileNameEl.length ? fileNameEl.text().trim() : `File_${dataId}`;
|
|
|
|
episodeDetails.push({
|
|
fid: dataId,
|
|
name: fileName,
|
|
episodeNum: getEpisodeNumberFromName(fileName)
|
|
});
|
|
});
|
|
|
|
// After parsing, save the extracted episodeDetails to its cache
|
|
// This will also save an empty array if parsing yields no episodes, preventing re-parsing of empty folders
|
|
await saveToCache(cacheKeyFolderParsed, episodeDetails, cacheSubDirFolderParsed);
|
|
if (episodeDetails.length > 0) {
|
|
// console.log(` SAVED PARSED episode list to cache: Season ${seasonNum}, Folder ${selectedFolder.id}`);
|
|
}
|
|
}
|
|
|
|
// Sort episodes by their number (whether from cache or freshly parsed)
|
|
// Ensure episodeDetails is sorted if populated
|
|
if (episodeDetails.length > 0) {
|
|
episodeDetails.sort((a, b) => a.episodeNum - b.episodeNum);
|
|
}
|
|
|
|
// If episode number specified, find matching episode
|
|
if (episodeNum !== null) {
|
|
// First try exact episode number match
|
|
for (const episode of episodeDetails) {
|
|
if (episode.episodeNum === episodeNum) {
|
|
selectedEpisode = episode;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If no match by number, try index-based (episode 1 = first file)
|
|
if (!selectedEpisode && episodeNum > 0 && episodeNum <= episodeDetails.length) {
|
|
selectedEpisode = episodeDetails[episodeNum - 1];
|
|
}
|
|
|
|
if (!selectedEpisode) {
|
|
console.log(`Could not find episode ${episodeNum} in season folder ${selectedFolder.name}`);
|
|
console.timeEnd(processTimerLabel);
|
|
return streamsForThisCall;
|
|
}
|
|
|
|
console.log(`Found episode: ${selectedEpisode.name} (FID: ${selectedEpisode.fid})`);
|
|
episodeFids.push(selectedEpisode.fid); // Now episodeFids is already declared
|
|
} else {
|
|
// If no episode specified, process all episodes
|
|
// Ensure episodeDetails is populated before mapping
|
|
if (episodeDetails.length > 0) {
|
|
episodeFids.push(...episodeDetails.map(ep => ep.fid)); // episodeFids is already declared
|
|
} else {
|
|
console.log(` No episode details found for folder ${selectedFolder.name} to extract FIDs for all episodes.`);
|
|
}
|
|
}
|
|
|
|
// Get video sources for each episode FID
|
|
if (resolveFids && episodeFids.length > 0) { // Check resolveFids flag here
|
|
const episodeTimerLabel = `processShowWithSeasonsEpisodes_fetchEpisodeSources_s${seasonNum}` + (episodeNum ? `_e${episodeNum}` : '_allEp_concurrent');
|
|
console.time(episodeTimerLabel);
|
|
const episodeSourcePromises = episodeFids.map(fid =>
|
|
fetchSourcesForSingleFid(fid, shareKey, regionPreference, userCookie)
|
|
.then(sources => ({ fid, sources }))
|
|
);
|
|
const episodeSourcesResults = await Promise.all(episodeSourcePromises);
|
|
console.timeEnd(episodeTimerLabel);
|
|
|
|
for (const result of episodeSourcesResults) {
|
|
// Check if result is defined and has sources
|
|
if (result && result.fid && result.sources && Array.isArray(result.sources)) {
|
|
const { fid, sources } = result;
|
|
|
|
for (const source of sources) {
|
|
const episodeDetail = episodeDetails.find(ep => ep.fid === fid);
|
|
const episodeName = episodeDetail ? episodeDetail.name : '';
|
|
|
|
const streamTitle = `${showboxTitle} - S${seasonNum}${episodeNum && selectedEpisode && fid === selectedEpisode.fid ? `E${episodeNum}` : (episodeDetail ? `E${episodeDetail.episodeNum}` : '')} - ${episodeName} - ${source.label}`;
|
|
streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall
|
|
name: streamTitle,
|
|
title: streamTitle,
|
|
url: source.url,
|
|
quality: parseQualityFromLabel(source.label),
|
|
provider: 'Febbox FID',
|
|
headers: {}
|
|
});
|
|
}
|
|
} else {
|
|
console.log(` Warning: Invalid result structure for an episode source: ${JSON.stringify(result)}`);
|
|
}
|
|
}
|
|
} else if (!resolveFids && episodeFids.length > 0) {
|
|
console.log(` Skipping FID resolution for ${episodeFids.length} episodes in S${seasonNum} as per request.`);
|
|
}
|
|
console.timeEnd(processTimerLabel);
|
|
return streamsForThisCall; // MODIFIED: Return the collected streams
|
|
};
|
|
|
|
// Helper function to get episode number from filename
|
|
const getEpisodeNumberFromName = (name) => {
|
|
const nameLower = name.toLowerCase();
|
|
|
|
// Common TV episode naming patterns
|
|
const patterns = [
|
|
/[._\s-]s\d{1,2}[._\s-]?e(\d{1,3})[._\s-]?/, // S01E01, s1e1
|
|
/[._\s-]e[cp]?[._\s-]?(\d{1,3})[._\s-]?/, // E01, EP01
|
|
/episode[._\s-]?(\d{1,3})/, // Episode 1
|
|
/part[._\s-]?(\d{1,3})/, // Part 1
|
|
/ep[._\s-]?(\d{1,3})/, // Ep 1
|
|
/pt[._\s-]?(\d{1,3})/ // Pt 1
|
|
];
|
|
|
|
for (const pattern of patterns) {
|
|
const match = nameLower.match(pattern);
|
|
if (match && match[1]) {
|
|
return parseInt(match[1], 10);
|
|
}
|
|
}
|
|
|
|
// Try to find standalone numbers that might be episode numbers
|
|
const simpleNumMatches = nameLower.match(/(?<![a-zA-Z])(\d{1,3})(?![a-zA-Z0-9])/g);
|
|
if (simpleNumMatches && simpleNumMatches.length === 1) {
|
|
const num = parseInt(simpleNumMatches[0], 10);
|
|
// Avoid quality indicators like 1080p or dimensions like 1920x1080
|
|
if (num > 0 && num < 200 &&
|
|
!((simpleNumMatches[0] + "p") === nameLower) &&
|
|
!nameLower.includes("x" + simpleNumMatches[0]) &&
|
|
!nameLower.includes("h" + simpleNumMatches[0]) &&
|
|
(nameLower.split(simpleNumMatches[0]).length - 1 <= 1 || !["1", "2"].includes(simpleNumMatches[0]))
|
|
) {
|
|
return num;
|
|
}
|
|
}
|
|
|
|
// Default to infinity (for sorting purposes)
|
|
return Infinity;
|
|
};
|
|
|
|
// Helper function to parse quality from label
|
|
const parseQualityFromLabel = (label) => {
|
|
if (!label) return "ORG";
|
|
|
|
const labelLower = String(label).toLowerCase();
|
|
|
|
if (labelLower.includes('1080p') || labelLower.includes('1080')) {
|
|
return "1080p";
|
|
} else if (labelLower.includes('720p') || labelLower.includes('720')) {
|
|
return "720p";
|
|
} else if (labelLower.includes('480p') || labelLower.includes('480')) {
|
|
return "480p";
|
|
} else if (labelLower.includes('360p') || labelLower.includes('360')) {
|
|
return "360p";
|
|
} else if (labelLower.includes('2160p') || labelLower.includes('2160') ||
|
|
labelLower.includes('4k') || labelLower.includes('uhd')) {
|
|
return "2160p";
|
|
} else if (labelLower.includes('hd')) {
|
|
return "720p"; // Assuming HD is 720p
|
|
} else if (labelLower.includes('sd')) {
|
|
return "480p"; // Assuming SD is 480p
|
|
}
|
|
|
|
// Use ORG (original) label for unknown quality
|
|
return "ORG";
|
|
};
|
|
|
|
// Helper function to parse size string to bytes
|
|
const parseSizeToBytes = (sizeString) => {
|
|
if (!sizeString || typeof sizeString !== 'string') return Number.MAX_SAFE_INTEGER;
|
|
|
|
const sizeLower = sizeString.toLowerCase();
|
|
|
|
if (sizeLower.includes('unknown') || sizeLower.includes('n/a')) {
|
|
return Number.MAX_SAFE_INTEGER; // Sort unknown/NA sizes last
|
|
}
|
|
|
|
const units = {
|
|
gb: 1024 * 1024 * 1024,
|
|
mb: 1024 * 1024,
|
|
kb: 1024,
|
|
b: 1
|
|
};
|
|
|
|
const match = sizeString.match(/([\d.]+)\s*(gb|mb|kb|b)/i);
|
|
if (match && match[1] && match[2]) {
|
|
const value = parseFloat(match[1]);
|
|
const unit = match[2].toLowerCase();
|
|
if (!isNaN(value) && units[unit]) {
|
|
return Math.floor(value * units[unit]);
|
|
}
|
|
}
|
|
return Number.MAX_SAFE_INTEGER; // Fallback for unparsed strings
|
|
};
|
|
|
|
// Helper function to extract codec details from a string (filename/label)
|
|
const extractCodecDetails = (text) => {
|
|
if (!text || typeof text !== 'string') return [];
|
|
const details = new Set();
|
|
const lowerText = text.toLowerCase();
|
|
|
|
// Video Codecs & Technologies
|
|
if (lowerText.includes('dolby vision') || lowerText.includes('dovi') || lowerText.includes('.dv.')) details.add('DV');
|
|
if (lowerText.includes('hdr10+') || lowerText.includes('hdr10plus')) details.add('HDR10+');
|
|
else if (lowerText.includes('hdr')) details.add('HDR'); // General HDR if not HDR10+
|
|
if (lowerText.includes('sdr')) details.add('SDR');
|
|
|
|
if (lowerText.includes('av1')) details.add('AV1');
|
|
else if (lowerText.includes('h265') || lowerText.includes('x265') || lowerText.includes('hevc')) details.add('H.265');
|
|
else if (lowerText.includes('h264') || lowerText.includes('x264') || lowerText.includes('avc')) details.add('H.264');
|
|
|
|
// Audio Codecs
|
|
if (lowerText.includes('atmos')) details.add('Atmos');
|
|
if (lowerText.includes('truehd') || lowerText.includes('true-hd')) details.add('TrueHD');
|
|
if (lowerText.includes('dts-hd ma') || lowerText.includes('dtshdma') || lowerText.includes('dts-hdhr')) details.add('DTS-HD MA');
|
|
else if (lowerText.includes('dts-hd')) details.add('DTS-HD'); // General DTS-HD if not MA/HR
|
|
else if (lowerText.includes('dts') && !lowerText.includes('dts-hd')) details.add('DTS'); // Plain DTS
|
|
|
|
if (lowerText.includes('eac3') || lowerText.includes('e-ac-3') || lowerText.includes('dd+') || lowerText.includes('ddplus')) details.add('EAC3');
|
|
else if (lowerText.includes('ac3') || (lowerText.includes('dd') && !lowerText.includes('dd+') && !lowerText.includes('ddp'))) details.add('AC3'); // Plain AC3/DD
|
|
|
|
if (lowerText.includes('aac')) details.add('AAC');
|
|
if (lowerText.includes('opus')) details.add('Opus');
|
|
if (lowerText.includes('mp3')) details.add('MP3');
|
|
|
|
// Bit depth (less common but useful)
|
|
if (lowerText.includes('10bit') || lowerText.includes('10-bit')) details.add('10-bit');
|
|
else if (lowerText.includes('8bit') || lowerText.includes('8-bit')) details.add('8-bit');
|
|
|
|
return Array.from(details);
|
|
};
|
|
|
|
// Utility function to sort streams by quality in order of resolution
|
|
const sortStreamsByQuality = (streams) => {
|
|
// Quality ranking for output ordering
|
|
const qualityOrder = {
|
|
"ORG": 1, // ORG will show at the top (since it's at the bottom of the list)
|
|
"2160p": 2,
|
|
"1080p": 3,
|
|
"720p": 4,
|
|
"480p": 5,
|
|
"360p": 6 // 360p will show at the bottom
|
|
};
|
|
|
|
// Provider priority ordering (lower number = higher priority)
|
|
const providerSortKeys = {
|
|
'ShowBox': 1,
|
|
'Xprime.tv': 2,
|
|
'HollyMovieHD': 3,
|
|
'Soaper TV': 4,
|
|
// Default for unknown providers
|
|
default: 99
|
|
};
|
|
|
|
return [...streams].sort((a, b) => {
|
|
const qualityA = a.quality || "ORG";
|
|
const qualityB = b.quality || "ORG";
|
|
|
|
const orderA = qualityOrder[qualityA] || 10;
|
|
const orderB = qualityOrder[qualityB] || 10;
|
|
|
|
// First, compare by quality order
|
|
if (orderA !== orderB) {
|
|
return orderA - orderB;
|
|
}
|
|
|
|
// If qualities are the same, compare by size (descending - larger sizes first means earlier in array)
|
|
const sizeAInBytes = parseSizeToBytes(a.size);
|
|
const sizeBInBytes = parseSizeToBytes(b.size);
|
|
|
|
if (sizeAInBytes !== sizeBInBytes) {
|
|
return sizeBInBytes - sizeAInBytes;
|
|
}
|
|
|
|
// If quality AND size are the same, compare by provider
|
|
const providerA = a.provider || 'default';
|
|
const providerB = b.provider || 'default';
|
|
|
|
const providerOrderA = providerSortKeys[providerA] || providerSortKeys.default;
|
|
const providerOrderB = providerSortKeys[providerB] || providerSortKeys.default;
|
|
|
|
return providerOrderA - providerOrderB;
|
|
});
|
|
}
|
|
|
|
// Initialize the cache directory
|
|
ensureCacheDir(CACHE_DIR).catch(console.error);
|
|
|
|
// Fallback cookie loader removed (legacy loadFallbackCookies was deprecated).
|
|
// No-op now: runtime only uses user-supplied cookies from config/override.
|
|
|
|
// PStream API Configuration
|
|
const PSTREAM_API_BASE_URL = 'https://fed-api.pstream.mov';
|
|
const PSTREAM_ACCEPT_HEADER = 'pstream.org';
|
|
|
|
// Region mapping for PStream API (FebBox regions -> PStream regions)
|
|
const PSTREAM_REGION_MAPPING = {
|
|
// US Regions
|
|
'USA7': 'East',
|
|
'USA6': 'West',
|
|
'USA5': 'East',
|
|
'USA4': 'West',
|
|
'USA3': 'West',
|
|
'USA2': 'West',
|
|
'USA1': 'West',
|
|
|
|
// European Regions
|
|
'UK3': 'Europe',
|
|
'FR1': 'Europe',
|
|
'DE2': 'Europe',
|
|
'EU1': 'Europe',
|
|
'EU2': 'Europe',
|
|
|
|
// Asian Regions
|
|
'HK1': 'Asia',
|
|
'IN1': 'Asia',
|
|
'SZ': 'Asia',
|
|
'ASIA1': 'Asia',
|
|
'ASIA2': 'Asia',
|
|
|
|
// Other Regions
|
|
'CA1': 'East', // Canada maps to East (US East)
|
|
'AU1': 'Asia' // Australia maps to Asia
|
|
};
|
|
|
|
// Function to map FebBox region to PStream region
|
|
const mapRegionForPStream = (febboxRegion) => {
|
|
if (!febboxRegion) {
|
|
return PSTREAM_REGION_MAPPING[DEFAULT_OSS_REGION] || 'East';
|
|
}
|
|
|
|
const mappedRegion = PSTREAM_REGION_MAPPING[febboxRegion];
|
|
if (mappedRegion) {
|
|
return mappedRegion;
|
|
}
|
|
|
|
// Fallback to East if region not found
|
|
console.log(`[PStream] Region ${febboxRegion} not found in mapping, using East as fallback`);
|
|
return 'East';
|
|
};
|
|
|
|
// Function to fetch streams from PStream API (new primary method)
|
|
const getStreamsFromPStreamAPI = async (imdbId, tmdbType, seasonNum = null, episodeNum = null, regionPreference = null, userCookie = null) => {
|
|
const timerLabel = `getStreamsFromPStreamAPI_${imdbId}_${tmdbType}` + (seasonNum ? `_s${seasonNum}` : '') + (episodeNum ? `_e${episodeNum}` : '');
|
|
console.time(timerLabel);
|
|
|
|
try {
|
|
// Build the PStream API URL
|
|
let apiUrl;
|
|
if (tmdbType === 'movie') {
|
|
apiUrl = `${PSTREAM_API_BASE_URL}/movie/${imdbId}`;
|
|
} else if (tmdbType === 'tv' && seasonNum !== null && episodeNum !== null) {
|
|
apiUrl = `${PSTREAM_API_BASE_URL}/tv/${imdbId}/${seasonNum}/${episodeNum}`;
|
|
} else {
|
|
console.log(`[PStream] Invalid parameters for ${tmdbType}: season=${seasonNum}, episode=${episodeNum}`);
|
|
return [];
|
|
}
|
|
|
|
console.log(`[PStream] Making request to: ${apiUrl}`);
|
|
|
|
// Get the region for PStream API
|
|
const pstreamRegion = mapRegionForPStream(regionPreference || global.lastRequestedRegion?.used);
|
|
|
|
// Prepare headers for PStream API
|
|
const headers = {
|
|
'accept': PSTREAM_ACCEPT_HEADER,
|
|
'accept-language': 'en-GB,en-US;q=0.9,en;q=0.8',
|
|
'origin': 'https://pstream.mov',
|
|
'referer': `https://pstream.mov/media/tmdb-${tmdbType}-${imdbId}`,
|
|
'region': pstreamRegion,
|
|
'sec-ch-ua': '"Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"',
|
|
'sec-ch-ua-mobile': '?0',
|
|
'sec-ch-ua-platform': '"macOS"',
|
|
'sec-fetch-dest': 'empty',
|
|
'sec-fetch-mode': 'cors',
|
|
'sec-fetch-site': 'same-site',
|
|
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36'
|
|
};
|
|
|
|
// Add ui-token if available
|
|
const uiToken = await getUiTokenForPStream(userCookie);
|
|
if (uiToken) {
|
|
headers['ui-token'] = uiToken;
|
|
console.log(`[PStream] Using ui-token for authentication`);
|
|
} else {
|
|
console.log(`[PStream] No ui-token available, making unauthenticated request`);
|
|
}
|
|
|
|
// Always use direct API URL
|
|
const finalApiUrl = apiUrl;
|
|
|
|
// Make the request
|
|
const response = await axios.get(finalApiUrl, {
|
|
headers: headers,
|
|
timeout: 30000
|
|
});
|
|
|
|
if (!response.data) {
|
|
console.log(`[PStream] No data received from API`);
|
|
return [];
|
|
}
|
|
|
|
console.log(`[PStream] Response received, processing streams...`);
|
|
|
|
// Process the PStream API response
|
|
const streams = processPStreamResponse(response.data, tmdbType, seasonNum, episodeNum);
|
|
|
|
console.timeEnd(timerLabel);
|
|
console.log(`[PStream] Found ${streams.length} streams`);
|
|
return streams;
|
|
|
|
} catch (error) {
|
|
console.error(`[PStream] Error fetching streams: ${error.message}`);
|
|
if (error.response) {
|
|
console.error(`[PStream] Status: ${error.response.status}, Data: ${JSON.stringify(error.response.data).substring(0, 200)}`);
|
|
}
|
|
console.timeEnd(timerLabel);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// Function to process PStream API response and convert to standard stream format
|
|
const processPStreamResponse = (apiResponse, tmdbType, seasonNum, episodeNum) => {
|
|
const streams = [];
|
|
|
|
try {
|
|
// Log the actual response structure for debugging
|
|
console.log(`[PStream] API Response structure:`, JSON.stringify(apiResponse, null, 2).substring(0, 1000));
|
|
console.log(`[PStream] API Response type:`, typeof apiResponse);
|
|
console.log(`[PStream] API Response keys:`, apiResponse ? Object.keys(apiResponse) : 'null/undefined');
|
|
|
|
// Check if response has streams object (PStream API format)
|
|
if (apiResponse && apiResponse.streams && typeof apiResponse.streams === 'object') {
|
|
console.log(`[PStream] Found streams object with ${Object.keys(apiResponse.streams).length} quality levels`);
|
|
|
|
// Process streams object format
|
|
for (const [quality, url] of Object.entries(apiResponse.streams)) {
|
|
if (url && typeof url === 'string') {
|
|
// Use "Auto" for name and actual filename for title
|
|
const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${quality}`;
|
|
|
|
streams.push({
|
|
name: streamTitle,
|
|
title: streamTitle,
|
|
url: url,
|
|
quality: parseQualityFromLabel(quality),
|
|
provider: 'PStream',
|
|
headers: {}
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(`[PStream] Successfully parsed ${streams.length} streams from streams object`);
|
|
return streams;
|
|
}
|
|
|
|
// Check if response has sources array (alternative format)
|
|
if (apiResponse && Array.isArray(apiResponse.sources)) {
|
|
console.log(`[PStream] Found sources array with ${apiResponse.sources.length} sources`);
|
|
|
|
for (const source of apiResponse.sources) {
|
|
if (!source.url || !source.quality) {
|
|
continue;
|
|
}
|
|
|
|
const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${source.quality}`;
|
|
|
|
streams.push({
|
|
name: streamTitle,
|
|
title: streamTitle,
|
|
url: source.url,
|
|
quality: parseQualityFromLabel(source.quality),
|
|
provider: 'PStream',
|
|
headers: {}
|
|
});
|
|
}
|
|
|
|
return streams;
|
|
}
|
|
|
|
// Check if response has data array (another alternative format)
|
|
if (apiResponse && Array.isArray(apiResponse.data)) {
|
|
console.log(`[PStream] Found 'data' array instead of 'sources'`);
|
|
|
|
for (const item of apiResponse.data) {
|
|
if (item.url || item.file) {
|
|
const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${item.quality || item.label || 'Unknown'}`;
|
|
|
|
streams.push({
|
|
name: streamTitle,
|
|
title: streamTitle,
|
|
url: item.url || item.file,
|
|
quality: parseQualityFromLabel(item.quality || item.label),
|
|
provider: 'PStream',
|
|
headers: {}
|
|
});
|
|
}
|
|
}
|
|
|
|
return streams;
|
|
}
|
|
|
|
// No recognized format found
|
|
console.log(`[PStream] No recognized stream format found in response`);
|
|
if (apiResponse && apiResponse.message) {
|
|
console.log(`[PStream] API returned message:`, apiResponse.message);
|
|
} else if (apiResponse && apiResponse.error) {
|
|
console.log(`[PStream] API returned error:`, apiResponse.error);
|
|
}
|
|
|
|
return streams;
|
|
|
|
} catch (error) {
|
|
console.error(`[PStream] Error processing API response: ${error.message}`);
|
|
}
|
|
|
|
return streams;
|
|
};
|
|
|
|
// Function to get TMDB data specifically for PStream API (extracts IMDB ID)
|
|
const getTmdbDataForPStream = async (tmdbType, tmdbId) => {
|
|
const cacheSubDir = 'tmdb_api';
|
|
const cacheKey = `tmdb-${tmdbType}-${tmdbId}.json`;
|
|
|
|
// Try cache first
|
|
let tmdbData = await getFromCache(cacheKey, cacheSubDir);
|
|
|
|
if (!tmdbData || process.env.DISABLE_CACHE === 'true') {
|
|
const TMDB_API_KEY = obtainTmdbKey();
|
|
const tmdbApiUrl = `${TMDB_BASE_URL}/${tmdbType}/${tmdbId}?api_key=${TMDB_API_KEY}`;
|
|
console.log(` [PStream] Fetching TMDB data from: ${tmdbApiUrl}`);
|
|
|
|
try {
|
|
const response = await axios.get(tmdbApiUrl, { timeout: 10000 });
|
|
tmdbData = response.data;
|
|
if (tmdbData) {
|
|
if (process.env.DISABLE_CACHE !== 'true') {
|
|
await saveToCache(cacheKey, tmdbData, cacheSubDir);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log(` [PStream] Error fetching TMDB data: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (!tmdbData) {
|
|
console.log(` [PStream] No TMDB data found for ${tmdbType}/${tmdbId}`);
|
|
return null;
|
|
}
|
|
|
|
// Extract IMDB ID
|
|
let imdbId = null;
|
|
const externalIdsCacheKey = `tmdb-${tmdbType}-${tmdbId}-external_ids.json`;
|
|
let externalIdsData = await getFromCache(externalIdsCacheKey, cacheSubDir);
|
|
|
|
if (!externalIdsData || process.env.DISABLE_CACHE === 'true') {
|
|
const TMDB_API_KEY = obtainTmdbKey();
|
|
const externalIdsApiUrl = `${TMDB_BASE_URL}/${tmdbType}/${tmdbId}/external_ids?api_key=${TMDB_API_KEY}`;
|
|
console.log(` [PStream] Fetching TMDB external IDs from: ${externalIdsApiUrl}`);
|
|
|
|
try {
|
|
const response = await axios.get(externalIdsApiUrl, { timeout: 10000 });
|
|
externalIdsData = response.data;
|
|
if (externalIdsData) {
|
|
if (process.env.DISABLE_CACHE !== 'true') {
|
|
await saveToCache(externalIdsCacheKey, externalIdsData, cacheSubDir);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log(` [PStream] Error fetching TMDB external IDs: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
if (externalIdsData && externalIdsData.imdb_id) {
|
|
imdbId = externalIdsData.imdb_id;
|
|
}
|
|
|
|
if (!imdbId) {
|
|
console.log(` [PStream] No IMDB ID found for ${tmdbType}/${tmdbId}`);
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
imdbId: imdbId,
|
|
title: tmdbType === 'movie' ? tmdbData.title : tmdbData.name,
|
|
year: tmdbData.release_date ? new Date(tmdbData.release_date).getFullYear() :
|
|
tmdbData.first_air_date ? new Date(tmdbData.first_air_date).getFullYear() : null
|
|
};
|
|
};
|
|
|
|
// Function to get ui-token for PStream API (handles JWT tokens)
|
|
const getUiTokenForPStream = async (userCookie = null) => {
|
|
// If user provided a JWT token directly, use it
|
|
if (userCookie && userCookie.startsWith('eyJ')) {
|
|
console.log('[PStream CookieManager] Using user-provided JWT token as ui-token');
|
|
return userCookie;
|
|
}
|
|
|
|
// If user provided a regular cookie, try to convert it to JWT format
|
|
if (userCookie && !userCookie.startsWith('eyJ')) {
|
|
console.log('[PStream CookieManager] Regular cookie provided, attempting to use as ui-token');
|
|
// For now, pass it through as-is - the API might handle conversion
|
|
return userCookie;
|
|
}
|
|
|
|
// Check global cookie
|
|
if (global.currentRequestUserCookie && global.currentRequestUserCookie.startsWith('eyJ')) {
|
|
console.log('[PStream CookieManager] Using JWT token from global state');
|
|
return global.currentRequestUserCookie;
|
|
}
|
|
|
|
// Try intelligent selection from cookies array if available
|
|
if (global.currentRequestConfig && Array.isArray(global.currentRequestConfig.cookies) && global.currentRequestConfig.cookies.length > 0) {
|
|
try {
|
|
const candidates = global.currentRequestConfig.cookies.filter(Boolean);
|
|
const flowResults = await Promise.all(candidates.map(async (c) => {
|
|
try {
|
|
const headers = { 'Cookie': c.startsWith('ui=') ? c : `ui=${c}`, 'Accept': 'application/json, text/javascript, */*; q=0.01' };
|
|
const resp = await axios.get('https://www.febbox.com/console/user_cards', { headers, timeout: 10000, validateStatus: () => true });
|
|
if (resp.status === 200 && resp.data && resp.data.data && resp.data.data.flow) {
|
|
const flow = resp.data.data.flow;
|
|
const remaining = (Number(flow.traffic_limit_mb) || 0) - (Number(flow.traffic_usage_mb) || 0);
|
|
return { cookie: c, remainingMB: remaining, ok: true };
|
|
}
|
|
} catch (e) { /* ignore per-candidate error */ }
|
|
return { cookie: c, remainingMB: -1, ok: false };
|
|
}));
|
|
flowResults.sort((a, b) => (b.remainingMB - a.remainingMB));
|
|
const best = flowResults[0];
|
|
if (best && best.ok) {
|
|
global.currentRequestUserCookie = best.cookie;
|
|
global.currentRequestUserCookieRemainingMB = best.remainingMB;
|
|
console.log(`[PStream CookieManager] Selected best ui-token by remaining quota: ${best.remainingMB} MB`);
|
|
// Return JWT directly if it is JWT, otherwise return as-is
|
|
return best.cookie;
|
|
}
|
|
} catch (e) {
|
|
console.warn(`[PStream CookieManager] Multi-cookie selection failed: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
console.log('[PStream CookieManager] No valid ui-token available');
|
|
return null;
|
|
};
|
|
|
|
module.exports = {
|
|
getStreamsFromTmdbId,
|
|
parseQualityFromLabel,
|
|
convertImdbToTmdb,
|
|
getShowboxUrlFromTmdbInfo,
|
|
ShowBoxScraper,
|
|
extractFidsFromFebboxPage,
|
|
processShowWithSeasonsEpisodes,
|
|
sortStreamsByQuality,
|
|
getStreamsFromPStreamAPI,
|
|
processPStreamResponse,
|
|
getTmdbDataForPStream,
|
|
getUiTokenForPStream
|
|
}; |