Files
TMDB-Embed-API/providers/Showbox.js
T

3091 lines
145 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 &quot; optional
const urlMatch = styleAttr.match(/url\((?:&quot;)?(https?:\/\/image\.tmdb\.org\/t\/p\/original)?([^&]+?)(?:&quot;)?\)/);
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({
title: streamTitle,
url: source.url,
quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(key5FromDirectSource || streamTitle)
});
}
// 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({
title: streamTitle,
url: source.url,
quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(source.detailedFilename || streamTitle)
});
}
}
} 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
title: streamTitle,
url: source.url,
quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(source.detailedFilename || streamTitle)
});
}
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
title: streamTitle,
url: source.url,
quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(source.detailedFilename || streamTitle)
});
}
}
}
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
title: streamTitle,
url: source.url,
quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(source.detailedFilename || streamTitle)
});
}
} 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 streamName = "Auto";
const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${quality}`;
streams.push({
name: streamName,
title: streamTitle,
url: url,
quality: parseQualityFromLabel(quality),
codecs: extractCodecDetails(apiResponse.name || quality), // Use filename for codec detection
size: apiResponse.size || 'Unknown size',
provider: 'PStream'
});
}
}
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 streamName = "Auto";
const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${source.quality}`;
streams.push({
name: streamName,
title: streamTitle,
url: source.url,
quality: parseQualityFromLabel(source.quality),
codecs: source.codec ? [source.codec] : [],
size: source.size || 'Unknown size',
provider: 'PStream'
});
}
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 streamName = "Auto";
const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${item.quality || item.label || 'Unknown'}`;
streams.push({
name: streamName,
title: streamTitle,
url: item.url || item.file,
quality: parseQualityFromLabel(item.quality || item.label),
codecs: item.codec ? [item.codec] : [],
size: item.size || 'Unknown size',
provider: 'PStream'
});
}
}
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
};