mirror of
https://github.com/Inside4ndroid/TMDB-Embed-API.git
synced 2026-10-22 01:25:10 +00:00
1863 lines
80 KiB
JavaScript
1863 lines
80 KiB
JavaScript
const https = require('https');
|
|
const http = require('http');
|
|
const { URL } = require('url');
|
|
const cheerio = require('cheerio');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
|
|
// Configuration
|
|
const DOMAINS_URL = 'https://raw.githubusercontent.com/phisher98/TVVVV/refs/heads/main/domains.json';
|
|
let cachedDomains = null;
|
|
|
|
// Permanent policy: block r2.dev links (previously optional via env flag)
|
|
console.log('[4KHDHub] r2.dev links will be removed (permanent policy)');
|
|
|
|
// --- Caching Configuration ---
|
|
const CACHE_ENABLED = process.env.DISABLE_CACHE !== 'true';
|
|
console.log(`[4KHDHub] Internal cache is ${CACHE_ENABLED ? 'enabled' : 'disabled'}.`);
|
|
const CACHE_DIR = process.env.VERCEL ? path.join('/tmp', '.4khdhub_cache') : path.join(__dirname, '.cache', '4khdhub');
|
|
|
|
// Caching layer simplified: currently inert placeholders (extend for FS persistence if desired)
|
|
|
|
// --- Caching Helper Functions ---
|
|
const ensureCacheDir = async () => {
|
|
if (!CACHE_ENABLED) return;
|
|
try {
|
|
await fs.mkdir(CACHE_DIR, { recursive: true });
|
|
} catch (error) {
|
|
if (error.code !== 'EEXIST') {
|
|
console.error(`[4KHDHub Cache] Error creating cache directory: ${error.message}`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const getFromCache = async (key) => {
|
|
if (!CACHE_ENABLED) return null;
|
|
return null; // no-op
|
|
};
|
|
|
|
const saveToCache = async (key, data) => {
|
|
if (!CACHE_ENABLED) return;
|
|
// no-op (placeholder for future FS caching)
|
|
};
|
|
|
|
// Initialize cache directory on startup
|
|
ensureCacheDir();
|
|
|
|
// Utility functions
|
|
function base64Decode(str) {
|
|
return Buffer.from(str, 'base64').toString('utf-8');
|
|
}
|
|
|
|
|
|
function rot13(str) {
|
|
return str.replace(/[A-Za-z]/g, function (char) {
|
|
const start = char <= 'Z' ? 65 : 97;
|
|
return String.fromCharCode(((char.charCodeAt(0) - start + 13) % 26) + start);
|
|
});
|
|
}
|
|
|
|
function validateUrl(url) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
|
|
// Skip validation for known reliable hosting services
|
|
const trustedHosts = [
|
|
'pixeldrain.dev'
|
|
];
|
|
|
|
const isTrustedHost = trustedHosts.some(host => urlObj.hostname.includes(host));
|
|
if (isTrustedHost) {
|
|
console.log(`[4KHDHub] Skipping validation for trusted host: ${urlObj.hostname}`);
|
|
resolve(true);
|
|
return;
|
|
}
|
|
|
|
const protocol = urlObj.protocol === 'https:' ? https : http;
|
|
|
|
// Add referer/origin for r2.dev validation attempts
|
|
const extraHeaders = {};
|
|
if (/r2\.dev$/i.test(urlObj.hostname)) {
|
|
const origin = `${urlObj.protocol}//${urlObj.hostname}`;
|
|
extraHeaders['Referer'] = origin + '/';
|
|
extraHeaders['Origin'] = origin;
|
|
console.log(`[4KHDHub] Adding Referer/Origin headers for validation of r2.dev URL: ${url}`);
|
|
}
|
|
|
|
const options = {
|
|
method: 'HEAD',
|
|
timeout: 15000,
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
...extraHeaders
|
|
}
|
|
};
|
|
|
|
const req = protocol.request(url, options, (res) => {
|
|
// Consider 2xx and 3xx status codes as valid, including 206 (Partial Content)
|
|
const isValid = res.statusCode >= 200 && res.statusCode < 400;
|
|
console.log(`[4KHDHub] URL validation for ${url}: ${res.statusCode} - ${isValid ? 'VALID' : 'INVALID'}`);
|
|
res.destroy(); // Close connection immediately
|
|
resolve(isValid);
|
|
});
|
|
|
|
req.on('error', (err) => {
|
|
console.log(`[4KHDHub] URL validation error for ${url}: ${err.message}`);
|
|
resolve(false);
|
|
});
|
|
|
|
req.on('timeout', () => {
|
|
console.log(`[4KHDHub] URL validation timeout for ${url}`);
|
|
req.destroy();
|
|
resolve(false);
|
|
});
|
|
|
|
req.setTimeout(15000);
|
|
req.end();
|
|
} catch (error) {
|
|
console.log(`[4KHDHub] URL validation parse error for ${url}: ${error.message}`);
|
|
resolve(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
function makeRequest(url, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const urlObj = new URL(url);
|
|
const isHttps = urlObj.protocol === 'https:';
|
|
const httpModule = isHttps ? https : http;
|
|
|
|
const requestOptions = {
|
|
hostname: urlObj.hostname,
|
|
port: urlObj.port || (isHttps ? 443 : 80),
|
|
path: urlObj.pathname + urlObj.search,
|
|
method: options.method || 'GET',
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
|
...options.headers
|
|
},
|
|
timeout: 30000
|
|
};
|
|
|
|
const req = httpModule.request(requestOptions, (res) => {
|
|
if (options.allowRedirects === false && (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303 || res.statusCode === 307 || res.statusCode === 308)) {
|
|
resolve({ statusCode: res.statusCode, headers: res.headers });
|
|
return;
|
|
}
|
|
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
if (options.parseHTML && data) {
|
|
const $ = cheerio.load(data);
|
|
resolve({ $: $, body: data, statusCode: res.statusCode, headers: res.headers });
|
|
} else {
|
|
resolve({ body: data, statusCode: res.statusCode, headers: res.headers });
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('error', reject);
|
|
req.on('timeout', () => reject(new Error('Request timeout')));
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Helper function to decode URL-encoded filenames and make them human-readable
|
|
function decodeFilename(filename) {
|
|
if (!filename) return filename;
|
|
|
|
try {
|
|
// Handle UTF-8 prefix and decode URL encoding
|
|
let decoded = filename;
|
|
|
|
// Remove UTF-8 prefix if present
|
|
if (decoded.startsWith('UTF-8')) {
|
|
decoded = decoded.substring(5);
|
|
}
|
|
|
|
// Decode URL encoding (%20 -> space, etc.)
|
|
decoded = decodeURIComponent(decoded);
|
|
|
|
return decoded;
|
|
} catch (error) {
|
|
console.log(`[4KHDHub] Error decoding filename: ${error.message}`);
|
|
return filename; // Return original if decoding fails
|
|
}
|
|
}
|
|
|
|
function getFilenameFromUrl(url) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
const isHttps = urlObj.protocol === 'https:';
|
|
const httpModule = isHttps ? https : http;
|
|
|
|
const requestOptions = {
|
|
hostname: urlObj.hostname,
|
|
port: urlObj.port || (isHttps ? 443 : 80),
|
|
path: urlObj.pathname + urlObj.search,
|
|
method: 'HEAD',
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
|
},
|
|
timeout: 10000
|
|
};
|
|
|
|
const req = httpModule.request(requestOptions, (res) => {
|
|
const contentDisposition = res.headers['content-disposition'];
|
|
let filename = null;
|
|
|
|
if (contentDisposition) {
|
|
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/i);
|
|
if (filenameMatch && filenameMatch[1]) {
|
|
filename = filenameMatch[1].replace(/["']/g, '');
|
|
}
|
|
}
|
|
|
|
if (!filename) {
|
|
// Extract from URL path
|
|
const pathParts = urlObj.pathname.split('/');
|
|
filename = pathParts[pathParts.length - 1];
|
|
if (filename && filename.includes('.')) {
|
|
filename = filename.replace(/\.[^.]+$/, ''); // Remove extension
|
|
}
|
|
}
|
|
|
|
// Decode the filename to make it human-readable
|
|
const decodedFilename = decodeFilename(filename);
|
|
resolve(decodedFilename || null);
|
|
});
|
|
|
|
req.on('error', () => resolve(null));
|
|
req.on('timeout', () => resolve(null));
|
|
req.end();
|
|
} catch (error) {
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
function getDomains() {
|
|
if (cachedDomains) {
|
|
return Promise.resolve(cachedDomains);
|
|
}
|
|
|
|
return makeRequest(DOMAINS_URL)
|
|
.then(response => {
|
|
cachedDomains = JSON.parse(response.body);
|
|
return cachedDomains;
|
|
})
|
|
.catch(error => {
|
|
console.error('[4KHDHub] Failed to fetch domains:', error.message);
|
|
return null;
|
|
});
|
|
}
|
|
|
|
function getRedirectLinks(url) {
|
|
return makeRequest(url)
|
|
.then(response => {
|
|
const doc = response.body;
|
|
const regex = /s\('o','([A-Za-z0-9+/=]+)'|ck\('_wp_http_\d+','([^']+)'/g;
|
|
let combinedString = '';
|
|
let match;
|
|
|
|
while ((match = regex.exec(doc)) !== null) {
|
|
const extractedValue = match[1] || match[2];
|
|
if (extractedValue) {
|
|
combinedString += extractedValue;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const decodedString = base64Decode(rot13(base64Decode(base64Decode(combinedString))));
|
|
const jsonObject = JSON.parse(decodedString);
|
|
const encodedurl = base64Decode(jsonObject.o || '').trim();
|
|
const data = base64Decode(jsonObject.data || '').trim();
|
|
const wphttp1 = (jsonObject.blog_url || '').trim();
|
|
|
|
if (encodedurl) {
|
|
return Promise.resolve(encodedurl);
|
|
}
|
|
|
|
if (wphttp1 && data) {
|
|
return makeRequest(`${wphttp1}?re=${data}`, { parseHTML: true })
|
|
.then(resp => resp.document.body.textContent.trim())
|
|
.catch(() => '');
|
|
}
|
|
|
|
return Promise.resolve('');
|
|
} catch (e) {
|
|
console.error('[4KHDHub] Error processing links:', e.message);
|
|
return Promise.resolve('');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('[4KHDHub] Error fetching redirect links:', error.message);
|
|
return Promise.resolve('');
|
|
});
|
|
}
|
|
|
|
function getIndexQuality(str) {
|
|
const match = (str || '').match(/(\d{3,4})[pP]/);
|
|
return match ? parseInt(match[1]) : 2160;
|
|
}
|
|
|
|
function getBaseUrl(url) {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
return `${urlObj.protocol}//${urlObj.host}`;
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function cleanTitle(title) {
|
|
// Decode URL-encoded title first
|
|
const decodedTitle = decodeFilename(title);
|
|
const parts = decodedTitle.split(/[.\-_]/);
|
|
|
|
const qualityTags = ['WEBRip', 'WEB-DL', 'WEB', 'BluRay', 'HDRip', 'DVDRip', 'HDTV', 'CAM', 'TS', 'R5', 'DVDScr', 'BRRip', 'BDRip', 'DVD', 'PDTV', 'HD'];
|
|
const audioTags = ['AAC', 'AC3', 'DTS', 'MP3', 'FLAC', 'DD5', 'EAC3', 'Atmos'];
|
|
const subTags = ['ESub', 'ESubs', 'Subs', 'MultiSub', 'NoSub', 'EnglishSub', 'HindiSub'];
|
|
const codecTags = ['x264', 'x265', 'H264', 'HEVC', 'AVC'];
|
|
|
|
const startIndex = parts.findIndex(part =>
|
|
qualityTags.some(tag => part.toLowerCase().includes(tag.toLowerCase()))
|
|
);
|
|
|
|
const endIndex = parts.map((part, index) => {
|
|
const hasTag = [...subTags, ...audioTags, ...codecTags].some(tag =>
|
|
part.toLowerCase().includes(tag.toLowerCase())
|
|
);
|
|
return hasTag ? index : -1;
|
|
}).filter(index => index !== -1).pop() || -1;
|
|
|
|
if (startIndex !== -1 && endIndex !== -1 && endIndex >= startIndex) {
|
|
return parts.slice(startIndex, endIndex + 1).join('.');
|
|
} else if (startIndex !== -1) {
|
|
return parts.slice(startIndex).join('.');
|
|
} else {
|
|
return parts.slice(-3).join('.');
|
|
}
|
|
}
|
|
|
|
// Enhanced title normalization with better handling of special cases
|
|
function normalizeTitle(title) {
|
|
return title
|
|
.toLowerCase()
|
|
// Handle common title variations
|
|
.replace(/&/g, 'and') // & -> and
|
|
.replace(/\b(the|a|an)\b/g, '') // Remove articles
|
|
// Handle common abbreviations and expansions
|
|
.replace(/\bvs\b/g, 'versus')
|
|
.replace(/\bversus\b/g, 'vs')
|
|
.replace(/\bdr\b/g, 'doctor')
|
|
.replace(/\bdoctor\b/g, 'dr')
|
|
.replace(/\bmr\b/g, 'mister')
|
|
.replace(/\bmister\b/g, 'mr')
|
|
.replace(/\bst\b/g, 'saint')
|
|
.replace(/\bsaint\b/g, 'st')
|
|
.replace(/\bmt\b/g, 'mount')
|
|
.replace(/\bmount\b/g, 'mt')
|
|
.replace(/[^a-z0-9\s]/g, ' ') // Remove special characters
|
|
.replace(/\s+/g, ' ') // Normalize whitespace
|
|
.trim();
|
|
}
|
|
|
|
// Extract year from title if present
|
|
function extractYear(title) {
|
|
const yearMatch = title.match(/\((19|20)\d{2}\)/);
|
|
return yearMatch ? parseInt(yearMatch[0].replace(/[()]/g, '')) : null;
|
|
}
|
|
|
|
// Remove year from title for cleaner comparison
|
|
function removeYear(title) {
|
|
return title.replace(/\s*\((19|20)\d{2}\)\s*/g, ' ').trim();
|
|
}
|
|
|
|
// Generate alternative search queries for better matching
|
|
function generateAlternativeQueries(title, originalTitle = null) {
|
|
const queries = new Set();
|
|
|
|
// Add the original title
|
|
queries.add(title);
|
|
|
|
// Add original title if different
|
|
if (originalTitle && originalTitle !== title) {
|
|
queries.add(originalTitle);
|
|
}
|
|
|
|
// Remove year and try again
|
|
const titleWithoutYear = removeYear(title);
|
|
if (titleWithoutYear !== title) {
|
|
queries.add(titleWithoutYear);
|
|
}
|
|
|
|
// Remove colons and other punctuation
|
|
queries.add(title.replace(/:/g, ''));
|
|
queries.add(title.replace(/[^a-zA-Z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim());
|
|
|
|
// Handle common title variations
|
|
const variations = [
|
|
title.replace(/\bPart\s+(\d+)\b/gi, 'Part $1'),
|
|
title.replace(/\bPart\s+(\d+)\b/gi, '$1'),
|
|
title.replace(/\b(\d+)\b/g, match => {
|
|
const num = parseInt(match);
|
|
const romans = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
|
|
return romans[num] || match;
|
|
}),
|
|
title.replace(/\b(I{1,3}|IV|V|VI{0,3}|IX|X)\b/g, match => {
|
|
const romans = {
|
|
'I': '1', 'II': '2', 'III': '3', 'IV': '4', 'V': '5',
|
|
'VI': '6', 'VII': '7', 'VIII': '8', 'IX': '9', 'X': '10'
|
|
};
|
|
return romans[match] || match;
|
|
})
|
|
];
|
|
|
|
variations.forEach(v => {
|
|
if (v && v !== title) queries.add(v);
|
|
});
|
|
|
|
// Remove duplicates and filter out empty strings
|
|
return Array.from(queries).filter(q => q && q.trim().length > 0);
|
|
}
|
|
|
|
// Enhanced similarity calculation with multiple algorithms
|
|
function calculateSimilarity(str1, str2) {
|
|
const s1 = normalizeTitle(str1);
|
|
const s2 = normalizeTitle(str2);
|
|
|
|
if (s1 === s2) return 1.0;
|
|
|
|
// Levenshtein distance
|
|
const levenshtein = calculateLevenshteinSimilarity(s1, s2);
|
|
|
|
// Jaccard similarity (word-based)
|
|
const jaccard = calculateJaccardSimilarity(s1, s2);
|
|
|
|
// Longest common subsequence
|
|
const lcs = calculateLCSSimilarity(s1, s2);
|
|
|
|
// Weighted combination of different similarity measures
|
|
return (levenshtein * 0.4) + (jaccard * 0.4) + (lcs * 0.2);
|
|
}
|
|
|
|
// Levenshtein distance similarity
|
|
function calculateLevenshteinSimilarity(s1, s2) {
|
|
const len1 = s1.length;
|
|
const len2 = s2.length;
|
|
|
|
if (len1 === 0) return len2 === 0 ? 1.0 : 0.0;
|
|
if (len2 === 0) return 0.0;
|
|
|
|
const matrix = Array(len1 + 1).fill().map(() => Array(len2 + 1).fill(0));
|
|
|
|
for (let i = 0; i <= len1; i++) matrix[i][0] = i;
|
|
for (let j = 0; j <= len2; j++) matrix[0][j] = j;
|
|
|
|
for (let i = 1; i <= len1; i++) {
|
|
for (let j = 1; j <= len2; j++) {
|
|
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
|
|
matrix[i][j] = Math.min(
|
|
matrix[i - 1][j] + 1, // deletion
|
|
matrix[i][j - 1] + 1, // insertion
|
|
matrix[i - 1][j - 1] + cost // substitution
|
|
);
|
|
}
|
|
}
|
|
|
|
const maxLen = Math.max(len1, len2);
|
|
return (maxLen - matrix[len1][len2]) / maxLen;
|
|
}
|
|
|
|
// Jaccard similarity for word-based comparison
|
|
function calculateJaccardSimilarity(s1, s2) {
|
|
const words1 = new Set(s1.split(' ').filter(w => w.length > 0));
|
|
const words2 = new Set(s2.split(' ').filter(w => w.length > 0));
|
|
|
|
const intersection = new Set([...words1].filter(w => words2.has(w)));
|
|
const union = new Set([...words1, ...words2]);
|
|
|
|
return union.size === 0 ? 0 : intersection.size / union.size;
|
|
}
|
|
|
|
// Longest Common Subsequence similarity
|
|
function calculateLCSSimilarity(s1, s2) {
|
|
const len1 = s1.length;
|
|
const len2 = s2.length;
|
|
|
|
if (len1 === 0 || len2 === 0) return 0;
|
|
|
|
const dp = Array(len1 + 1).fill().map(() => Array(len2 + 1).fill(0));
|
|
|
|
for (let i = 1; i <= len1; i++) {
|
|
for (let j = 1; j <= len2; j++) {
|
|
if (s1[i - 1] === s2[j - 1]) {
|
|
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
} else {
|
|
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
const maxLen = Math.max(len1, len2);
|
|
return dp[len1][len2] / maxLen;
|
|
}
|
|
|
|
// Enhanced word containment check with fuzzy matching
|
|
function containsWords(title, query) {
|
|
const titleWords = normalizeTitle(title).split(' ').filter(w => w.length > 1);
|
|
const queryWords = normalizeTitle(query).split(' ').filter(w => w.length > 1);
|
|
|
|
let matchedWords = 0;
|
|
|
|
for (const queryWord of queryWords) {
|
|
const found = titleWords.some(titleWord => {
|
|
// Exact match
|
|
if (titleWord === queryWord) return true;
|
|
|
|
// Substring match
|
|
if (titleWord.includes(queryWord) || queryWord.includes(titleWord)) return true;
|
|
|
|
// Fuzzy match for longer words (allow 1 character difference)
|
|
if (queryWord.length > 3 && titleWord.length > 3) {
|
|
const similarity = calculateLevenshteinSimilarity(titleWord, queryWord);
|
|
return similarity > 0.8;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
if (found) matchedWords++;
|
|
}
|
|
|
|
// Require at least 70% of query words to be matched
|
|
return matchedWords / queryWords.length >= 0.7;
|
|
}
|
|
|
|
// Enhanced best match finder with improved scoring
|
|
function findBestMatch(results, query, tmdbYear = null) {
|
|
if (results.length === 0) return null;
|
|
if (results.length === 1) return results[0];
|
|
|
|
console.log(`[4KHDHub] Finding best match for: "${query}" (Year: ${tmdbYear || 'N/A'})`);
|
|
|
|
// Strict year gating: if TMDB year is known, only consider exact matches first,
|
|
// then allow unknown-year entries as fallback. Reject clear mismatches.
|
|
let candidateResults = results;
|
|
if (tmdbYear) {
|
|
const tmdbYearInt = parseInt(tmdbYear);
|
|
const exactYearMatches = results.filter(r => {
|
|
const y = r.year || extractYear(r.title);
|
|
return y && parseInt(y) === tmdbYearInt;
|
|
});
|
|
if (exactYearMatches.length > 0) {
|
|
candidateResults = exactYearMatches;
|
|
console.log(`[4KHDHub] Year filter: using ${exactYearMatches.length} exact year matches for ${tmdbYearInt}`);
|
|
} else {
|
|
const unknownYear = results.filter(r => {
|
|
const y = r.year || extractYear(r.title);
|
|
return !y;
|
|
});
|
|
if (unknownYear.length > 0) {
|
|
candidateResults = unknownYear;
|
|
console.log(`[4KHDHub] Year filter: no exact matches; falling back to ${unknownYear.length} unknown-year results`);
|
|
} else {
|
|
console.log(`[4KHDHub] Year filter: no exact or unknown-year matches for ${tmdbYearInt}; rejecting results`);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Score each result
|
|
const scoredResults = candidateResults.map(result => {
|
|
let score = 0;
|
|
// Use year from search result metadata if available, otherwise extract from title
|
|
const resultYear = result.year || extractYear(result.title);
|
|
const queryWithoutYear = removeYear(query);
|
|
const resultWithoutYear = removeYear(result.title);
|
|
|
|
// Exact match gets highest score (without year)
|
|
if (normalizeTitle(resultWithoutYear) === normalizeTitle(queryWithoutYear)) {
|
|
score += 100;
|
|
}
|
|
|
|
// Enhanced similarity score (0-60 points)
|
|
const similarity = calculateSimilarity(resultWithoutYear, queryWithoutYear);
|
|
score += similarity * 60;
|
|
|
|
// Word containment bonus (0-25 points)
|
|
if (containsWords(result.title, query)) {
|
|
score += 25;
|
|
}
|
|
|
|
// Year matching bonus (strict): only reward exact match when TMDB year is known
|
|
if (tmdbYear && resultYear) {
|
|
if (parseInt(tmdbYear) === parseInt(resultYear)) {
|
|
score += 30; // Strong reward for exact year
|
|
}
|
|
} else if (resultYear && !tmdbYear) {
|
|
score += 5; // Slight bonus for having year info when TMDB year unknown
|
|
}
|
|
|
|
// Length similarity bonus (0-10 points)
|
|
const lengthDiff = Math.abs(resultWithoutYear.length - queryWithoutYear.length);
|
|
score += Math.max(0, 10 - lengthDiff / 3);
|
|
|
|
// Prefer results with quality indicators
|
|
if (result.title.match(/\b(1080p|720p|4K|2160p|BluRay|WEB-DL)\b/i)) {
|
|
score += 3;
|
|
}
|
|
|
|
// Penalty for results with too many extra words
|
|
const queryWordCount = queryWithoutYear.split(' ').filter(w => w.length > 0).length;
|
|
const resultWordCount = resultWithoutYear.split(' ').filter(w => w.length > 0).length;
|
|
if (resultWordCount > queryWordCount * 2) {
|
|
score -= 10;
|
|
}
|
|
|
|
return { ...result, score, similarity, resultYear };
|
|
});
|
|
|
|
// Sort by score (highest first)
|
|
scoredResults.sort((a, b) => b.score - a.score);
|
|
|
|
console.log('[4KHDHub] Title matching scores:');
|
|
scoredResults.slice(0, 5).forEach((result, index) => {
|
|
console.log(`${index + 1}. ${result.title} (Score: ${result.score.toFixed(1)}, Similarity: ${(result.similarity * 100).toFixed(1)}%, Year: ${result.resultYear || 'N/A'})`);
|
|
});
|
|
|
|
// Additional validation: ensure the best match has a reasonable score
|
|
const bestResult = scoredResults[0];
|
|
// Final guard: if TMDB year is known and best has a conflicting year, reject
|
|
if (tmdbYear && bestResult.resultYear && parseInt(bestResult.resultYear) !== parseInt(tmdbYear)) {
|
|
console.log(`[4KHDHub] Best match year mismatch (best=${bestResult.resultYear}, tmdb=${tmdbYear}), rejecting`);
|
|
return null;
|
|
}
|
|
if (bestResult.score < 30) {
|
|
console.log(`[4KHDHub] Best match score too low (${bestResult.score.toFixed(1)}), rejecting`);
|
|
return null;
|
|
}
|
|
|
|
return bestResult;
|
|
}
|
|
|
|
function extractHubCloudLinks(url, referer) {
|
|
console.log(`[4KHDHub] Starting HubCloud extraction for: ${url}`);
|
|
const baseUrl = getBaseUrl(url);
|
|
|
|
return makeRequest(url, { parseHTML: true })
|
|
.then(response => {
|
|
const $ = response.$;
|
|
console.log(`[4KHDHub] Got HubCloud page, looking for download element...`);
|
|
|
|
// Check if this is already a hubcloud.php URL
|
|
let href;
|
|
if (url.includes('hubcloud.php')) {
|
|
href = url;
|
|
console.log(`[4KHDHub] Already a hubcloud.php URL: ${href}`);
|
|
} else {
|
|
const downloadElement = $('#download');
|
|
if (downloadElement.length === 0) {
|
|
console.log('[4KHDHub] Download element #download not found, trying alternatives...');
|
|
// Try alternative selectors
|
|
const alternatives = ['a[href*="hubcloud.php"]', '.download-btn', 'a[href*="download"]'];
|
|
let found = false;
|
|
|
|
for (const selector of alternatives) {
|
|
const altElement = $(selector).first();
|
|
if (altElement.length > 0) {
|
|
const rawHref = altElement.attr('href');
|
|
if (rawHref) {
|
|
href = rawHref.startsWith('http') ? rawHref : `${baseUrl.replace(/\/$/, '')}/${rawHref.replace(/^\//, '')}`;
|
|
console.log(`[4KHDHub] Found download link with selector ${selector}: ${href}`);
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
throw new Error('Download element not found with any selector');
|
|
}
|
|
} else {
|
|
const rawHref = downloadElement.attr('href');
|
|
if (!rawHref) {
|
|
throw new Error('Download href not found');
|
|
}
|
|
|
|
href = rawHref.startsWith('http') ? rawHref : `${baseUrl.replace(/\/$/, '')}/${rawHref.replace(/^\//, '')}`;
|
|
console.log(`[4KHDHub] Found download href: ${href}`);
|
|
}
|
|
}
|
|
|
|
console.log(`[4KHDHub] Making request to HubCloud download page: ${href}`);
|
|
return makeRequest(href, { parseHTML: true });
|
|
})
|
|
.then(response => {
|
|
const $ = response.$;
|
|
|
|
console.log(`[4KHDHub] Processing HubCloud download page...`);
|
|
|
|
// Extract quality and size information
|
|
const size = $('i#size').text() || '';
|
|
const header = $('div.card-header').text() || '';
|
|
const quality = getIndexQuality(header);
|
|
const headerDetails = cleanTitle(header);
|
|
|
|
console.log(`[4KHDHub] Extracted info - Size: ${size}, Header: ${header}, Quality: ${quality}, HeaderDetails: ${headerDetails}`);
|
|
|
|
// Extract just the quality for clean naming
|
|
const qualityLabel = quality ? ` - ${quality}p` : '';
|
|
|
|
// We'll build the title format later after getting actual filename from HEAD request
|
|
|
|
// Find download buttons
|
|
const downloadButtons = $('div.card-body h2 a.btn');
|
|
console.log(`[4KHDHub] Found ${downloadButtons.length} download buttons`);
|
|
|
|
if (downloadButtons.length === 0) {
|
|
// Try alternative selectors for download buttons
|
|
const altSelectors = ['a.btn', '.btn', 'a[href]'];
|
|
for (const selector of altSelectors) {
|
|
const altButtons = $(selector);
|
|
if (altButtons.length > 0) {
|
|
console.log(`[4KHDHub] Found ${altButtons.length} buttons with alternative selector: ${selector}`);
|
|
altButtons.each((index, btn) => {
|
|
const $btn = $(btn);
|
|
const link = $btn.attr('href');
|
|
const text = $btn.text();
|
|
console.log(`[4KHDHub] Button ${index + 1}: ${text} -> ${link}`);
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const promises = downloadButtons.get().map((button, index) => {
|
|
return new Promise((resolve) => {
|
|
const $button = $(button);
|
|
const link = $button.attr('href');
|
|
const text = $button.text();
|
|
|
|
console.log(`[4KHDHub] Processing button ${index + 1}: "${text}" -> ${link}`);
|
|
|
|
if (!link) {
|
|
console.log(`[4KHDHub] Button ${index + 1} has no link`);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
const buttonBaseUrl = getBaseUrl(link);
|
|
|
|
if (text.includes('FSL Server')) {
|
|
console.log(`[4KHDHub] Button ${index + 1} is FSL Server`);
|
|
// Get actual filename from HEAD request
|
|
getFilenameFromUrl(link)
|
|
.then(actualFilename => {
|
|
const displayFilename = actualFilename || headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
let headers = {};
|
|
try {
|
|
const host = new URL(link).hostname;
|
|
if (/r2\.dev$/i.test(host)) {
|
|
headers = { Referer: baseUrl + '/', Origin: baseUrl };
|
|
console.log('[4KHDHub] Added Referer/Origin headers for r2.dev FSL link');
|
|
}
|
|
} catch {
|
|
// Ignore URL parsing errors
|
|
}
|
|
|
|
resolve({
|
|
name: `4KHDHub - FSL Server${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: link,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers
|
|
});
|
|
})
|
|
.catch(() => {
|
|
const displayFilename = headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
let headers = {};
|
|
try {
|
|
const host = new URL(link).hostname;
|
|
if (/r2\.dev$/i.test(host)) {
|
|
headers = { Referer: baseUrl + '/', Origin: baseUrl };
|
|
console.log('[4KHDHub] Added Referer/Origin headers for r2.dev FSL link (fallback)');
|
|
}
|
|
} catch {
|
|
// Ignore URL parsing errors
|
|
}
|
|
|
|
resolve({
|
|
name: `4KHDHub - FSL Server${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: link,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers
|
|
});
|
|
});
|
|
} else if (text.includes('Download File')) {
|
|
console.log(`[4KHDHub] Button ${index + 1} is Download File`);
|
|
// Get actual filename from HEAD request
|
|
getFilenameFromUrl(link)
|
|
.then(actualFilename => {
|
|
const displayFilename = actualFilename || headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
resolve({
|
|
name: `4KHDHub - HubCloud${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: link,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
});
|
|
})
|
|
.catch(() => {
|
|
const displayFilename = headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
resolve({
|
|
name: `4KHDHub - HubCloud${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: link,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
});
|
|
});
|
|
} else if (text.includes('BuzzServer')) {
|
|
console.log(`[4KHDHub] Button ${index + 1} is BuzzServer, following redirect...`);
|
|
// Handle BuzzServer redirect
|
|
makeRequest(`${link}/download`, {
|
|
parseHTML: false,
|
|
allowRedirects: false,
|
|
headers: { 'Referer': link }
|
|
})
|
|
.then(response => {
|
|
const redirectUrl = response.headers['hx-redirect'] || response.headers['location'];
|
|
if (redirectUrl) {
|
|
console.log(`[4KHDHub] BuzzServer redirect found: ${redirectUrl}`);
|
|
const finalUrl = buttonBaseUrl + redirectUrl;
|
|
// Get actual filename from HEAD request
|
|
getFilenameFromUrl(finalUrl)
|
|
.then(actualFilename => {
|
|
const displayFilename = actualFilename || headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
resolve({
|
|
name: `4KHDHub - BuzzServer${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: finalUrl,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
});
|
|
})
|
|
.catch(() => {
|
|
const displayFilename = headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
resolve({
|
|
name: `4KHDHub - BuzzServer${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: finalUrl,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
});
|
|
});
|
|
} else {
|
|
console.log(`[4KHDHub] BuzzServer redirect not found`);
|
|
resolve(null);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.log(`[4KHDHub] BuzzServer redirect failed: ${err.message}`);
|
|
resolve(null);
|
|
});
|
|
} else if (link.includes('pixeldra')) {
|
|
console.log(`[4KHDHub] Button ${index + 1} is Pixeldrain`);
|
|
|
|
// Convert pixeldrain.net/u/ID format to pixeldrain.net/api/file/ID format
|
|
let convertedLink = link;
|
|
const pixeldrainMatch = link.match(/pixeldrain\.net\/u\/([a-zA-Z0-9]+)/);
|
|
if (pixeldrainMatch) {
|
|
const fileId = pixeldrainMatch[1];
|
|
convertedLink = `https://pixeldrain.net/api/file/${fileId}`;
|
|
console.log(`[4KHDHub] Converted Pixeldrain URL from ${link} to ${convertedLink}`);
|
|
}
|
|
|
|
// Get actual filename from HEAD request
|
|
getFilenameFromUrl(convertedLink)
|
|
.then(actualFilename => {
|
|
const displayFilename = actualFilename || headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
resolve({
|
|
name: `4KHDHub - Pixeldrain${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: convertedLink,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
});
|
|
})
|
|
.catch(() => {
|
|
const displayFilename = headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
resolve({
|
|
name: `4KHDHub - Pixeldrain${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: convertedLink,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
});
|
|
});
|
|
} else if (text.includes('S3 Server')) {
|
|
console.log(`[4KHDHub] Button ${index + 1} is S3 Server`);
|
|
// Get actual filename from HEAD request
|
|
getFilenameFromUrl(link)
|
|
.then(actualFilename => {
|
|
const displayFilename = actualFilename || headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
resolve({
|
|
name: `4KHDHub - S3 Server${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: link,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
});
|
|
})
|
|
.catch(() => {
|
|
const displayFilename = headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
resolve({
|
|
name: `4KHDHub - S3 Server${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: link,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
});
|
|
});
|
|
} else if (text.includes('10Gbps')) {
|
|
console.log(`[4KHDHub] Button ${index + 1} is 10Gbps server, following redirects...`);
|
|
// Handle 10Gbps server with multiple redirects
|
|
let currentLink = link;
|
|
|
|
const followRedirects = () => {
|
|
return makeRequest(currentLink, {
|
|
parseHTML: false,
|
|
allowRedirects: false
|
|
})
|
|
.then(response => {
|
|
const redirectUrl = response.headers['location'];
|
|
if (!redirectUrl) {
|
|
throw new Error('No redirect found');
|
|
}
|
|
|
|
console.log(`[4KHDHub] 10Gbps redirect: ${redirectUrl}`);
|
|
|
|
if (redirectUrl.includes('id=')) {
|
|
// Final redirect, extract the link parameter
|
|
const finalLink = redirectUrl.split('link=')[1];
|
|
if (finalLink) {
|
|
console.log(`[4KHDHub] 10Gbps final link: ${finalLink}`);
|
|
const decodedUrl = decodeURIComponent(finalLink);
|
|
// Get actual filename from HEAD request
|
|
return getFilenameFromUrl(decodedUrl)
|
|
.then(actualFilename => {
|
|
const displayFilename = actualFilename || headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
return {
|
|
name: `4KHDHub - 10Gbps Server${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: decodedUrl,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
};
|
|
})
|
|
.catch(() => {
|
|
const displayFilename = headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
return {
|
|
name: `4KHDHub - 10Gbps Server${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: decodedUrl,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
};
|
|
});
|
|
}
|
|
throw new Error('Final link not found');
|
|
} else {
|
|
currentLink = redirectUrl;
|
|
return followRedirects();
|
|
}
|
|
});
|
|
};
|
|
|
|
followRedirects()
|
|
.then(result => {
|
|
console.log(`[4KHDHub] 10Gbps processing completed`);
|
|
resolve(result);
|
|
})
|
|
.catch(err => {
|
|
console.log(`[4KHDHub] 10Gbps processing failed: ${err.message}`);
|
|
resolve(null);
|
|
});
|
|
} else {
|
|
console.log(`[4KHDHub] Button ${index + 1} is generic link`);
|
|
// Generic link - Get actual filename from HEAD request
|
|
getFilenameFromUrl(link)
|
|
.then(actualFilename => {
|
|
const displayFilename = actualFilename || headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
resolve({
|
|
name: `4KHDHub - HubCloud${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: link,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
});
|
|
})
|
|
.catch(() => {
|
|
const displayFilename = headerDetails || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
resolve({
|
|
name: `4KHDHub - HubCloud${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: link,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
return Promise.all(promises)
|
|
.then(results => {
|
|
const validResults = results.filter(result => result !== null);
|
|
console.log(`[4KHDHub] HubCloud extraction completed, found ${validResults.length} valid links`);
|
|
return validResults;
|
|
});
|
|
})
|
|
.catch(error => {
|
|
console.error(`[4KHDHub] HubCloud extraction error for ${url}:`, error.message);
|
|
return [];
|
|
});
|
|
}
|
|
|
|
function searchContent(query) {
|
|
return getDomains()
|
|
.then(domains => {
|
|
if (!domains || !domains['4khdhub']) {
|
|
throw new Error('Failed to get domain information');
|
|
}
|
|
|
|
const baseUrl = domains['4khdhub'];
|
|
const searchUrl = `${baseUrl}/?s=${encodeURIComponent(query)}`;
|
|
return makeRequest(searchUrl, { parseHTML: true })
|
|
.then(response => ({ response, baseUrl }));
|
|
})
|
|
.then(({ response, baseUrl }) => {
|
|
const $ = response.$;
|
|
const results = [];
|
|
|
|
$('div.card-grid a').each((index, card) => {
|
|
const $card = $(card);
|
|
const title = $card.find('h3').text();
|
|
const href = $card.attr('href');
|
|
const posterUrl = $card.find('img').attr('src');
|
|
|
|
// Extract year from movie-card-meta element
|
|
const metaElement = $card.find('.movie-card-meta');
|
|
let year = null;
|
|
if (metaElement.length > 0) {
|
|
const metaText = metaElement.text().trim();
|
|
const yearMatch = metaText.match(/(19|20)\d{2}/);
|
|
if (yearMatch) {
|
|
year = parseInt(yearMatch[0]);
|
|
}
|
|
}
|
|
|
|
if (title && href) {
|
|
// Convert relative URLs to absolute URLs
|
|
const absoluteUrl = href.startsWith('http') ? href : `${baseUrl}${href.startsWith('/') ? '' : '/'}${href}`;
|
|
results.push({
|
|
title: title.trim(),
|
|
url: absoluteUrl,
|
|
poster: posterUrl || '',
|
|
year: year
|
|
});
|
|
}
|
|
});
|
|
|
|
return results;
|
|
});
|
|
}
|
|
|
|
function loadContent(url) {
|
|
return makeRequest(url, { parseHTML: true })
|
|
.then(response => {
|
|
const $ = response.$;
|
|
const title = $('h1.page-title').text().split('(')[0].trim() || '';
|
|
const poster = $('meta[property="og:image"]').attr('content') || '';
|
|
const tags = $('div.mt-2 span.badge').map((i, el) => $(el).text()).get();
|
|
const year = parseInt($('div.mt-2 span').text()) || null;
|
|
const description = $('div.content-section p.mt-4').text().trim() || '';
|
|
const trailer = $('#trailer-btn').attr('data-trailer-url') || '';
|
|
|
|
const isMovie = tags.includes('Movies');
|
|
|
|
// Try multiple selectors to find download links
|
|
let hrefs = [];
|
|
const selectors = [
|
|
'div.download-item a',
|
|
'.download-item a',
|
|
'a[href*="hubdrive"]',
|
|
'a[href*="hubcloud"]',
|
|
'a[href*="drive"]',
|
|
'.btn[href]',
|
|
'a.btn'
|
|
];
|
|
|
|
for (const selector of selectors) {
|
|
const links = $(selector)
|
|
.map((i, a) => $(a).attr('href'))
|
|
.get()
|
|
.filter(href => href && href.trim());
|
|
if (links.length > 0) {
|
|
hrefs = links;
|
|
console.log(`[4KHDHub] Found ${links.length} links using selector: ${selector}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (hrefs.length === 0) {
|
|
console.log('[4KHDHub] No download links found. Available links on page:');
|
|
const allLinks = $('a[href]')
|
|
.map((i, a) => $(a).attr('href'))
|
|
.get()
|
|
.filter(href => href && href.includes('http'))
|
|
.slice(0, 10); // Show first 10 links
|
|
console.log(allLinks);
|
|
}
|
|
|
|
const content = {
|
|
title,
|
|
poster,
|
|
tags,
|
|
year,
|
|
description,
|
|
trailer,
|
|
type: isMovie ? 'movie' : 'series'
|
|
};
|
|
|
|
if (isMovie) {
|
|
content.downloadLinks = hrefs;
|
|
return Promise.resolve(content);
|
|
} else {
|
|
// Handle TV series episodes
|
|
const episodesMap = new Map(); // used to dedupe episodes below
|
|
|
|
console.log(`[4KHDHub] Looking for episode structure...`);
|
|
const seasonItems = $('div.episodes-list div.season-item');
|
|
console.log(`[4KHDHub] Found ${seasonItems.length} season items`);
|
|
|
|
if (seasonItems.length === 0) {
|
|
// Try alternative episode structure selectors
|
|
const altSelectors = [
|
|
'div.season-item',
|
|
'.episode-item',
|
|
'.episode-download',
|
|
'div[class*="episode"]',
|
|
'div[class*="season"]'
|
|
];
|
|
|
|
for (const selector of altSelectors) {
|
|
const items = $(selector);
|
|
if (items.length > 0) {
|
|
console.log(`[4KHDHub] Found ${items.length} items with selector: ${selector}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If no episode structure found, treat all found links as general series links
|
|
if (hrefs.length > 0) {
|
|
console.log(`[4KHDHub] No episode structure found, using general links for series`);
|
|
// Create a single episode entry with all links
|
|
content.episodes = [{
|
|
season: 1,
|
|
episode: 1,
|
|
downloadLinks: hrefs
|
|
}];
|
|
} else {
|
|
content.episodes = [];
|
|
}
|
|
} else {
|
|
seasonItems.each((i, seasonElement) => {
|
|
const $seasonElement = $(seasonElement);
|
|
const seasonText = $seasonElement.find('div.episode-number').text() || '';
|
|
const seasonMatch = seasonText.match(/S?([1-9][0-9]*)/);
|
|
const season = seasonMatch ? parseInt(seasonMatch[1]) : null;
|
|
|
|
const episodeItems = $seasonElement.find('div.episode-download-item');
|
|
episodeItems.each((j, episodeItem) => {
|
|
const $episodeItem = $(episodeItem);
|
|
const episodeText = $episodeItem.find('div.episode-file-info span.badge-psa').text() || '';
|
|
const episodeMatch = episodeText.match(/Episode-0*([1-9][0-9]*)/);
|
|
const episode = episodeMatch ? parseInt(episodeMatch[1]) : null;
|
|
|
|
const episodeHrefs = $episodeItem.find('a')
|
|
.map((k, a) => $(a).attr('href'))
|
|
.get()
|
|
.filter(href => href && href.trim());
|
|
|
|
if (season && episode && episodeHrefs.length > 0) {
|
|
const key = `${season}-${episode}`;
|
|
if (!episodesMap.has(key)) {
|
|
episodesMap.set(key, {
|
|
season,
|
|
episode,
|
|
downloadLinks: []
|
|
});
|
|
}
|
|
episodesMap.get(key).downloadLinks.push(...episodeHrefs);
|
|
}
|
|
});
|
|
});
|
|
|
|
content.episodes = Array.from(episodesMap.values()).map(ep => ({
|
|
...ep,
|
|
downloadLinks: [...new Set(ep.downloadLinks)] // Remove duplicates
|
|
}));
|
|
}
|
|
|
|
console.log(`[4KHDHub] Found ${content.episodes.length} episodes with links`);
|
|
return Promise.resolve(content);
|
|
}
|
|
});
|
|
}
|
|
|
|
function extractStreamingLinks(downloadLinks) {
|
|
console.log(`[4KHDHub] Processing ${downloadLinks.length} download links...`);
|
|
|
|
const promises = downloadLinks.map((link, index) => {
|
|
return new Promise((resolve) => {
|
|
console.log(`[4KHDHub] Processing link ${index + 1}: ${link}`);
|
|
|
|
// Check if link needs redirect processing
|
|
if (link.toLowerCase().includes('id=')) {
|
|
console.log(`[4KHDHub] Link ${index + 1} needs redirect processing`);
|
|
getRedirectLinks(link)
|
|
.then(resolvedLink => {
|
|
if (resolvedLink) {
|
|
console.log(`[4KHDHub] Link ${index + 1} resolved to: ${resolvedLink}`);
|
|
processExtractorLink(resolvedLink, resolve, index + 1);
|
|
} else {
|
|
console.log(`[4KHDHub] Link ${index + 1} redirect resolution failed`);
|
|
resolve(null);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error(`[4KHDHub] Redirect failed for link ${index + 1} (${link}):`, err.message);
|
|
resolve(null);
|
|
});
|
|
} else {
|
|
processExtractorLink(link, resolve, index + 1);
|
|
}
|
|
});
|
|
});
|
|
|
|
return Promise.all(promises)
|
|
.then(results => {
|
|
const validResults = results.filter(r => r !== null).flat();
|
|
const filtered = validResults.filter(link => {
|
|
if (!link || !link.url) return false;
|
|
const lower = link.url.toLowerCase();
|
|
if (lower.endsWith('.zip')) {
|
|
console.log(`[4KHDHub] Skipping archive (zip) link: ${link.url}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
console.log(`[4KHDHub] Post-processing: kept ${filtered.length}, skipped ${validResults.length - filtered.length} zip archive links`);
|
|
return filtered;
|
|
});
|
|
}
|
|
|
|
function extractHubDriveLinks(url, referer) {
|
|
console.log(`[4KHDHub] Starting HubDrive extraction for: ${url}`);
|
|
|
|
return makeRequest(url, { parseHTML: true })
|
|
.then(response => {
|
|
const $ = response.$;
|
|
|
|
console.log(`[4KHDHub] Got HubDrive page, looking for download button...`);
|
|
|
|
// Extract filename and size information
|
|
const size = $('i#size').text() || '';
|
|
const header = $('div.card-header').text() || '';
|
|
const quality = getIndexQuality(header);
|
|
const headerDetails = cleanTitle(header);
|
|
|
|
console.log(`[4KHDHub] HubDrive extracted info - Size: ${size}, Header: ${header}, Quality: ${quality}, HeaderDetails: ${headerDetails}`);
|
|
|
|
// Extract filename from header for title display
|
|
let filename = headerDetails || header || 'Unknown';
|
|
// Clean up the filename by removing common prefixes and file extensions
|
|
filename = filename.replace(/^4kHDHub\.com\s*[-_]?\s*/i, '')
|
|
.replace(/\.[a-z0-9]{2,4}$/i, '')
|
|
.replace(/[._]/g, ' ')
|
|
.trim();
|
|
|
|
// Use the exact selector from Kotlin code
|
|
const downloadBtn = $('.btn.btn-primary.btn-user.btn-success1.m-1').first();
|
|
|
|
if (downloadBtn.length === 0) {
|
|
console.log('[4KHDHub] Primary download button not found, trying alternative selectors...');
|
|
// Try alternative selectors
|
|
const alternatives = [
|
|
'a.btn.btn-primary',
|
|
'.btn-primary',
|
|
'a[href*="download"]',
|
|
'a.btn'
|
|
];
|
|
|
|
let foundBtn = null;
|
|
for (const selector of alternatives) {
|
|
foundBtn = $(selector).first();
|
|
if (foundBtn.length > 0) {
|
|
console.log(`[4KHDHub] Found download button with selector: ${selector}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!foundBtn || foundBtn.length === 0) {
|
|
throw new Error('Download button not found with any selector');
|
|
}
|
|
|
|
const href = foundBtn.attr('href');
|
|
if (!href) {
|
|
throw new Error('Download link not found');
|
|
}
|
|
|
|
console.log(`[4KHDHub] Found HubDrive download link: ${href}`);
|
|
return processHubDriveLink(href, referer, filename, size, quality);
|
|
}
|
|
|
|
const href = downloadBtn.attr('href');
|
|
if (!href) {
|
|
throw new Error('Download link not found');
|
|
}
|
|
|
|
console.log(`[4KHDHub] Found HubDrive download link: ${href}`);
|
|
return processHubDriveLink(href, referer, filename, size, quality);
|
|
})
|
|
.catch(error => {
|
|
console.error(`[4KHDHub] Error extracting HubDrive links for ${url}:`, error.message);
|
|
return [];
|
|
});
|
|
}
|
|
|
|
function processHubDriveLink(href, referer, filename = 'Unknown', size = '', quality = 1080) {
|
|
// Check if it's a HubCloud link
|
|
if (href.toLowerCase().includes('hubcloud')) {
|
|
console.log('[4KHDHub] HubDrive link redirects to HubCloud, processing...');
|
|
return extractHubCloudLinks(href, '4KHDHub');
|
|
} else {
|
|
console.log('[4KHDHub] HubDrive direct link found');
|
|
// Direct link or other extractor
|
|
const qualityLabel = quality ? ` - ${quality}p` : '';
|
|
|
|
// Build labelExtras like the original extractor
|
|
const labelExtras = [];
|
|
if (filename && filename !== 'Unknown') labelExtras.push(`[${filename}]`);
|
|
if (size) labelExtras.push(`[${size}]`);
|
|
// labelExtra removed (not used)
|
|
|
|
// Get actual filename from HEAD request
|
|
return getFilenameFromUrl(href)
|
|
.then(actualFilename => {
|
|
const displayFilename = actualFilename || filename || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
return [{
|
|
name: `4KHDHub - HubDrive${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: href,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
}];
|
|
})
|
|
.catch(() => {
|
|
const displayFilename = filename || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
if (size) titleParts.push(size);
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
return [{
|
|
name: `4KHDHub - HubDrive${qualityLabel}`,
|
|
title: finalTitle,
|
|
url: href,
|
|
quality: quality,
|
|
provider: '4khdhub',
|
|
headers: {}
|
|
}];
|
|
});
|
|
}
|
|
}
|
|
|
|
function processExtractorLink(link, resolve, linkNumber) {
|
|
const linkLower = link.toLowerCase();
|
|
|
|
console.log(`[4KHDHub] Checking extractors for link ${linkNumber}: ${link}`);
|
|
|
|
if (linkLower.includes('hubdrive')) {
|
|
console.log(`[4KHDHub] Link ${linkNumber} matched HubDrive extractor`);
|
|
extractHubDriveLinks(link, '4KHDHub')
|
|
.then(links => {
|
|
console.log(`[4KHDHub] HubDrive extraction completed for link ${linkNumber}:`, links);
|
|
resolve(links);
|
|
})
|
|
.catch(err => {
|
|
console.error(`[4KHDHub] HubDrive extraction failed for link ${linkNumber} (${link}):`, err.message);
|
|
resolve(null);
|
|
});
|
|
} else if (linkLower.includes('hubcloud')) {
|
|
console.log(`[4KHDHub] Link ${linkNumber} matched HubCloud extractor`);
|
|
extractHubCloudLinks(link, '4KHDHub')
|
|
.then(links => {
|
|
console.log(`[4KHDHub] HubCloud extraction completed for link ${linkNumber}:`, links);
|
|
resolve(links);
|
|
})
|
|
.catch(err => {
|
|
console.error(`[4KHDHub] HubCloud extraction failed for link ${linkNumber} (${link}):`, err.message);
|
|
resolve(null);
|
|
});
|
|
} else {
|
|
console.log(`[4KHDHub] No extractor matched for link ${linkNumber}: ${link}`);
|
|
// Try to extract any direct streaming URLs from the link
|
|
if (link.includes('http') && (link.includes('.mp4') || link.includes('.mkv') || link.includes('.avi'))) {
|
|
console.log(`[4KHDHub] Link ${linkNumber} appears to be a direct video link`);
|
|
// Extract filename from URL
|
|
const urlParts = link.split('/');
|
|
const filename = urlParts[urlParts.length - 1].replace(/\.[^/.]+$/, '').replace(/[._]/g, ' ');
|
|
|
|
// Build labelExtras like the original extractor
|
|
const labelExtras = [];
|
|
if (filename) labelExtras.push(`[${filename}]`);
|
|
labelExtras.push('[Direct Link]');
|
|
// labelExtra removed (not used)
|
|
|
|
// Get actual filename from HEAD request
|
|
getFilenameFromUrl(link)
|
|
.then(actualFilename => {
|
|
const displayFilename = actualFilename || filename || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
titleParts.push('[Direct Link]');
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
resolve([{
|
|
name: '4KHDHub Direct Link',
|
|
title: finalTitle,
|
|
url: link,
|
|
quality: 1080,
|
|
provider: "4khdhub",
|
|
headers: {}
|
|
}]);
|
|
})
|
|
.catch(() => {
|
|
const displayFilename = filename || 'Unknown';
|
|
const titleParts = [];
|
|
if (displayFilename) titleParts.push(displayFilename);
|
|
titleParts.push('[Direct Link]');
|
|
const finalTitle = titleParts.join('\n');
|
|
|
|
resolve([{
|
|
name: '4KHDHub Direct Link',
|
|
title: finalTitle,
|
|
url: link,
|
|
quality: 1080,
|
|
provider: "4khdhub",
|
|
headers: {}
|
|
}]);
|
|
});
|
|
} else {
|
|
resolve(null);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to get TMDB details
|
|
async function getTMDBDetails(tmdbId, mediaType) {
|
|
const { getTmdbApiKey } = require('../utils/tmdbKey');
|
|
const TMDB_API_KEY = getTmdbApiKey();
|
|
if (!TMDB_API_KEY) {
|
|
console.warn('[4KHDHub] TMDB API key missing; skipping TMDB metadata fetch');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
console.log(`[4KHDHub] Fetching ${mediaType} details for TMDB ID: ${tmdbId}`);
|
|
const response = await makeRequest(`https://api.themoviedb.org/3/${mediaType}/${tmdbId}?api_key=${TMDB_API_KEY}`);
|
|
const data = JSON.parse(response.body);
|
|
|
|
if (mediaType === 'movie') {
|
|
return {
|
|
title: data.title,
|
|
original_title: data.original_title,
|
|
year: data.release_date ? data.release_date.split('-')[0] : null
|
|
};
|
|
} else {
|
|
return {
|
|
title: data.name,
|
|
original_title: data.original_name,
|
|
year: data.first_air_date ? data.first_air_date.split('-')[0] : null
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error(`[4KHDHub] Error fetching details from TMDB:`, error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Main function to get streams for the addon
|
|
async function get4KHDHubStreams(tmdbId, type, season = null, episode = null) {
|
|
try {
|
|
console.log(`[4KHDHub] Starting search for TMDB ID: ${tmdbId}, Type: ${type}${season ? `, Season: ${season}` : ''}${episode ? `, Episode: ${episode}` : ''}`);
|
|
|
|
// Create cache key for resolved file hosting URLs
|
|
const cacheKey = `4khdhub_resolved_urls_v3_${tmdbId}_${type}${season ? `_s${season}e${episode}` : ''}`;
|
|
|
|
let streamingLinks = [];
|
|
|
|
// 1. Check cache for resolved file hosting URLs first
|
|
const cachedResolvedUrls = await getFromCache(cacheKey);
|
|
if (cachedResolvedUrls && cachedResolvedUrls.length > 0) {
|
|
console.log(`[4KHDHub] Cache HIT for ${cacheKey}. Using ${cachedResolvedUrls.length} cached resolved URLs.`);
|
|
// Process cached resolved URLs directly to final streaming links
|
|
console.log(`[4KHDHub] Processing ${cachedResolvedUrls.length} cached resolved URLs to get streaming links.`);
|
|
streamingLinks = await extractStreamingLinks(cachedResolvedUrls);
|
|
} else {
|
|
if (cachedResolvedUrls && cachedResolvedUrls.length === 0) {
|
|
console.log(`[4KHDHub] Cache contains empty data for ${cacheKey}. Refetching from source.`);
|
|
} else {
|
|
console.log(`[4KHDHub] Cache MISS for ${cacheKey}. Fetching from source.`);
|
|
}
|
|
|
|
// Map type to TMDB API format
|
|
const tmdbType = type === 'series' ? 'tv' : type;
|
|
|
|
// Get TMDB details to get the actual title
|
|
const tmdbDetails = await getTMDBDetails(tmdbId, tmdbType);
|
|
if (!tmdbDetails || !tmdbDetails.title) {
|
|
console.log(`[4KHDHub] Could not fetch TMDB details for ID: ${tmdbId}`);
|
|
return [];
|
|
}
|
|
|
|
console.log(`[4KHDHub] TMDB Details: ${tmdbDetails.title} (${tmdbDetails.year || 'N/A'})`);
|
|
|
|
// Enhanced search with fallback strategies
|
|
let searchResults = [];
|
|
let bestMatch = null;
|
|
|
|
// Primary search using the actual title
|
|
const searchQuery = tmdbDetails.title;
|
|
searchResults = await searchContent(searchQuery);
|
|
console.log(`[4KHDHub] Primary search found ${searchResults.length} results`);
|
|
|
|
if (searchResults.length > 0) {
|
|
bestMatch = findBestMatch(searchResults, tmdbDetails.title, tmdbDetails.year);
|
|
}
|
|
|
|
// Fallback search strategies if no good match found
|
|
if (!bestMatch && searchResults.length > 0) {
|
|
console.log(`[4KHDHub] No good match from primary search, trying fallback strategies...`);
|
|
|
|
// Try search without year
|
|
const titleWithoutYear = removeYear(tmdbDetails.title);
|
|
if (titleWithoutYear !== tmdbDetails.title) {
|
|
console.log(`[4KHDHub] Trying search without year: "${titleWithoutYear}"`);
|
|
const fallbackResults = await searchContent(titleWithoutYear);
|
|
if (fallbackResults.length > 0) {
|
|
const fallbackMatch = findBestMatch(fallbackResults, tmdbDetails.title, tmdbDetails.year);
|
|
if (fallbackMatch && (!bestMatch || fallbackMatch.score > bestMatch.score)) {
|
|
bestMatch = fallbackMatch;
|
|
searchResults = fallbackResults;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try search with comprehensive alternative title formats
|
|
if (!bestMatch) {
|
|
const alternativeQueries = generateAlternativeQueries(
|
|
tmdbDetails.title,
|
|
tmdbDetails.original_title
|
|
).filter(query => query !== tmdbDetails.title); // Exclude the original title we already tried
|
|
|
|
for (const altQuery of alternativeQueries) {
|
|
console.log(`[4KHDHub] Trying alternative search: "${altQuery}"`);
|
|
const altResults = await searchContent(altQuery);
|
|
if (altResults.length > 0) {
|
|
const altMatch = findBestMatch(altResults, tmdbDetails.title, tmdbDetails.year);
|
|
if (altMatch && (!bestMatch || altMatch.score > bestMatch.score)) {
|
|
bestMatch = altMatch;
|
|
searchResults = altResults;
|
|
console.log(`[4KHDHub] Found better match with query: "${altQuery}" (score: ${altMatch.score})`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (searchResults.length === 0) {
|
|
console.log(`[4KHDHub] No search results found for any query variation`);
|
|
return [];
|
|
}
|
|
|
|
if (!bestMatch) {
|
|
console.log(`[4KHDHub] No suitable match found for: ${tmdbDetails.title}`);
|
|
return [];
|
|
}
|
|
|
|
console.log(`[4KHDHub] Using best match: ${bestMatch.title}`);
|
|
|
|
const content = await loadContent(bestMatch.url);
|
|
|
|
let downloadLinks = [];
|
|
|
|
if (type === 'movie') {
|
|
downloadLinks = content.downloadLinks || [];
|
|
} else if ((type === 'series' || type === 'tv') && season && episode) {
|
|
console.log(`[4KHDHub] Looking for Season ${season}, Episode ${episode}`);
|
|
console.log(`[4KHDHub] Available episodes:`, content.episodes?.map(ep => `S${ep.season}E${ep.episode} (${ep.downloadLinks?.length || 0} links)`));
|
|
|
|
const targetEpisode = content.episodes?.find(ep =>
|
|
ep.season === parseInt(season) && ep.episode === parseInt(episode)
|
|
);
|
|
|
|
if (targetEpisode) {
|
|
console.log(`[4KHDHub] Found target episode S${targetEpisode.season}E${targetEpisode.episode} with ${targetEpisode.downloadLinks?.length || 0} links`);
|
|
downloadLinks = targetEpisode.downloadLinks || [];
|
|
} else {
|
|
console.log(`[4KHDHub] Target episode S${season}E${episode} not found`);
|
|
}
|
|
}
|
|
|
|
if (downloadLinks.length === 0) {
|
|
console.log(`[4KHDHub] No download links found`);
|
|
return [];
|
|
}
|
|
|
|
// Resolve redirect URLs to actual file hosting URLs
|
|
console.log(`[4KHDHub] Resolving ${downloadLinks.length} redirect URLs to file hosting URLs...`);
|
|
const resolvedUrls = [];
|
|
|
|
for (let i = 0; i < downloadLinks.length; i++) {
|
|
const link = downloadLinks[i];
|
|
console.log(`[4KHDHub] Resolving link ${i + 1}/${downloadLinks.length}: ${link}`);
|
|
|
|
try {
|
|
if (link.toLowerCase().includes('id=')) {
|
|
// This is a redirect URL, resolve it
|
|
const resolvedUrl = await getRedirectLinks(link);
|
|
if (resolvedUrl && resolvedUrl.trim()) {
|
|
console.log(`[4KHDHub] Link ${i + 1} resolved to: ${resolvedUrl}`);
|
|
resolvedUrls.push(resolvedUrl);
|
|
} else {
|
|
console.log(`[4KHDHub] Link ${i + 1} resolution failed or returned empty`);
|
|
}
|
|
} else {
|
|
// Direct URL, use as-is
|
|
console.log(`[4KHDHub] Link ${i + 1} is direct URL: ${link}`);
|
|
resolvedUrls.push(link);
|
|
}
|
|
} catch (error) {
|
|
console.error(`[4KHDHub] Error resolving link ${i + 1} (${link}):`, error.message);
|
|
}
|
|
}
|
|
|
|
if (resolvedUrls.length === 0) {
|
|
console.log(`[4KHDHub] No URLs resolved successfully`);
|
|
return [];
|
|
}
|
|
|
|
// Cache the resolved file hosting URLs
|
|
console.log(`[4KHDHub] Caching ${resolvedUrls.length} resolved URLs for key: ${cacheKey}`);
|
|
await saveToCache(cacheKey, resolvedUrls);
|
|
|
|
// Process resolved URLs to get final streaming links
|
|
console.log(`[4KHDHub] Processing ${resolvedUrls.length} resolved URLs to get streaming links.`);
|
|
streamingLinks = await extractStreamingLinks(resolvedUrls);
|
|
}
|
|
|
|
// Filter out suspicious AMP/redirect URLs
|
|
const filteredLinks = streamingLinks.filter(link => {
|
|
const url = link.url.toLowerCase();
|
|
const suspiciousPatterns = [
|
|
'www-google-com.cdn.ampproject.org',
|
|
'bloggingvector.shop',
|
|
'cdn.ampproject.org'
|
|
];
|
|
|
|
const isSuspicious = suspiciousPatterns.some(pattern => url.includes(pattern));
|
|
if (isSuspicious) {
|
|
console.log(`[4KHDHub] Filtered out suspicious URL: ${link.url}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Remove duplicates based on URL
|
|
const uniqueLinks = [];
|
|
const seenUrls = new Set();
|
|
|
|
for (const link of filteredLinks) {
|
|
if (!seenUrls.has(link.url)) {
|
|
seenUrls.add(link.url);
|
|
uniqueLinks.push(link);
|
|
}
|
|
}
|
|
|
|
console.log(`[4KHDHub] Processing ${uniqueLinks.length} unique links (${streamingLinks.length - filteredLinks.length} suspicious URLs filtered, ${filteredLinks.length - uniqueLinks.length} duplicates removed)`);
|
|
|
|
// Validate URLs if DISABLE_4KHDHUB_URL_VALIDATION is false
|
|
let validatedLinks = uniqueLinks;
|
|
const disableValidation = process.env.DISABLE_4KHDHUB_URL_VALIDATION === 'true';
|
|
|
|
if (!disableValidation) {
|
|
console.log(`[4KHDHub] URL validation enabled, validating ${uniqueLinks.length} links...`);
|
|
const validationPromises = uniqueLinks.map(async (link) => {
|
|
const isValid = await validateUrl(link.url);
|
|
return isValid ? link : null;
|
|
});
|
|
|
|
const validationResults = await Promise.all(validationPromises);
|
|
validatedLinks = validationResults.filter(link => link !== null);
|
|
|
|
console.log(`[4KHDHub] URL validation complete: ${validatedLinks.length}/${uniqueLinks.length} links are valid`);
|
|
} else {
|
|
console.log(`[4KHDHub] URL validation disabled, skipping validation`);
|
|
}
|
|
|
|
// Normalize to generic stream object format
|
|
let streams = validatedLinks.map(link => ({
|
|
name: link.name, // Don't add prefix since it's already included
|
|
title: link.title || link.name,
|
|
url: link.url,
|
|
quality: `${link.quality}p`,
|
|
provider: "4khdhub",
|
|
headers: {}
|
|
}));
|
|
|
|
// Always remove r2.dev links
|
|
{
|
|
const before = streams.length;
|
|
streams = streams.filter(s => {
|
|
try { return !new URL(s.url).hostname.endsWith('r2.dev'); } catch { return true; }
|
|
});
|
|
const removed = before - streams.length;
|
|
if (removed > 0) console.log(`[4KHDHub] Removed ${removed} r2.dev streams (permanent policy)`);
|
|
}
|
|
|
|
// Host distribution instrumentation
|
|
try {
|
|
const hostCounts = streams.reduce((acc, s) => {
|
|
try {
|
|
const h = new URL(s.url).hostname;
|
|
acc[h] = (acc[h] || 0) + 1;
|
|
} catch {
|
|
// Ignore URL parsing errors
|
|
}
|
|
return acc;
|
|
}, {});
|
|
console.log('[4KHDHub] Final host distribution:', hostCounts);
|
|
} catch {
|
|
// Ignore URL parsing errors
|
|
}
|
|
|
|
console.log(`[4KHDHub] Returning ${streams.length} streams`);
|
|
return streams;
|
|
|
|
} catch (error) {
|
|
console.error(`[4KHDHub] Error getting streams:`, error.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
get4KHDHubStreams
|
|
}; |