Update index.js
Browse files
index.js
CHANGED
|
@@ -14,36 +14,210 @@ app.use((req, res, next) => {
|
|
| 14 |
next();
|
| 15 |
});
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
const MAX_SIZE_GB_MOVIE = 4;
|
| 22 |
const MAX_SIZE_GB_SERIES = 1.5;
|
| 23 |
const LINKS_PER_QUALITY = 5;
|
| 24 |
-
const ALLOWED_QUALITIES = ['4k', '1080p', '720p', '576p', '480p', 'webrip'];
|
| 25 |
|
| 26 |
const QUALITY_RANK = {
|
| 27 |
'1080p': 1, '720p': 2, '576p': 3, '480p': 4,
|
| 28 |
'4k': 5, '2160p': 5, 'webrip': 6, 'webdl': 6,
|
| 29 |
};
|
| 30 |
|
| 31 |
-
const
|
| 32 |
-
'udp://tracker.opentrackr.org:1337/announce',
|
| 33 |
-
'udp://open.stealth.si:80/announce',
|
| 34 |
-
'udp://open.demonii.com:1337/announce',
|
| 35 |
-
'udp://tracker.torrent.eu.org:451/announce',
|
| 36 |
-
'udp://p4p.arenabg.com:1337/announce',
|
| 37 |
-
'udp://exodus.desync.com:6969/announce',
|
| 38 |
-
'udp://tracker.openbittorrent.com:6969/announce',
|
| 39 |
-
'udp://tracker.dler.org:6969/announce',
|
| 40 |
-
'https://tracker.moeblog.cn:443/announce',
|
| 41 |
-
'https://tracker.zhuqiy.com:443/announce',
|
| 42 |
-
].map(t => '&tr=' + encodeURIComponent(t)).join('');
|
| 43 |
-
|
| 44 |
-
function buildMagnet(hash, name) {
|
| 45 |
-
return 'magnet:?xt=urn:btih:' + hash.toLowerCase() + '&dn=' + encodeURIComponent(name) + TR;
|
| 46 |
-
}
|
| 47 |
|
| 48 |
function getQuality(str = '') {
|
| 49 |
const s = str.toLowerCase();
|
|
@@ -57,66 +231,33 @@ function getQuality(str = '') {
|
|
| 57 |
return null;
|
| 58 |
}
|
| 59 |
|
| 60 |
-
function getSizeGB(stream) {
|
| 61 |
-
const raw = stream.title || stream.name || '';
|
| 62 |
-
const m = raw.match(/πΎ\s*([\d.]+)\s*(GB|MB)/i) || raw.match(/([\d.]+)\s*(GB|MB)/i);
|
| 63 |
-
if (!m) return null;
|
| 64 |
-
const val = parseFloat(m[1]);
|
| 65 |
-
return m[2].toUpperCase() === 'GB' ? val : val / 1024;
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
function getSeeders(stream) {
|
| 69 |
-
if (stream.
|
| 70 |
if (stream.behaviorHints?.seeders) return stream.behaviorHints.seeders;
|
| 71 |
-
|
| 72 |
-
return m ? parseInt(m[1], 10) : 0;
|
| 73 |
}
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
[
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
[/\bnorwegian\b/, 'Norwegian'],
|
| 97 |
-
[/\bdanish\b/, 'Danish'],
|
| 98 |
-
[/\bfinnish\b/, 'Finnish'],
|
| 99 |
-
[/\bromanian\b/, 'Romanian'],
|
| 100 |
-
[/\bgreek\b/, 'Greek'],
|
| 101 |
-
[/\bhebrew\b/, 'Hebrew'],
|
| 102 |
-
[/\bthai\b/, 'Thai'],
|
| 103 |
-
[/\bindonesian\b/, 'Indonesian'],
|
| 104 |
-
[/\bvietnamese\b/, 'Vietnamese'],
|
| 105 |
-
[/\btamil\b/, 'Tamil'],
|
| 106 |
-
[/\btelugu\b/, 'Telugu'],
|
| 107 |
-
[/\burdu\b/, 'Urdu'],
|
| 108 |
-
[/\bdubbed\b/, 'Dubbed'],
|
| 109 |
-
[/\bdual[\s\-]audio\b/, 'Dual Audio'],
|
| 110 |
-
[/\bmulti[\s\-]audio\b/, 'Multi Audio'],
|
| 111 |
-
[/\bmulti\b/, 'Multi'],
|
| 112 |
-
];
|
| 113 |
-
for (const [re, label] of checks) {
|
| 114 |
-
if (re.test(lower)) {
|
| 115 |
-
if (label === 'Multi' && found.includes('Multi Audio')) continue;
|
| 116 |
-
found.push(label);
|
| 117 |
-
}
|
| 118 |
-
}
|
| 119 |
-
return [...new Set(found)].join(' / ');
|
| 120 |
}
|
| 121 |
|
| 122 |
function processStreams(streams, type) {
|
|
@@ -124,17 +265,16 @@ function processStreams(streams, type) {
|
|
| 124 |
|
| 125 |
const filtered = streams.filter(s => {
|
| 126 |
if (!s.infoHash && !s.url) return false;
|
| 127 |
-
if (s._provider && !ALLOWED_PROVIDERS.includes(s._provider.toLowerCase())) return false;
|
| 128 |
const q = s._quality || getQuality(s.title || s.name || '');
|
| 129 |
if (!q || !ALLOWED_QUALITIES.includes(q)) return false;
|
| 130 |
-
const sizeGB = s._sizeGB
|
| 131 |
-
if (sizeGB !== null && sizeGB > maxSize) return false;
|
| 132 |
return true;
|
| 133 |
});
|
| 134 |
|
| 135 |
const seen = new Set();
|
| 136 |
const unique = filtered.filter(s => {
|
| 137 |
-
const key = (s.infoHash || '').toLowerCase();
|
| 138 |
if (!key) return true;
|
| 139 |
if (seen.has(key)) return false;
|
| 140 |
seen.add(key);
|
|
@@ -150,8 +290,8 @@ function processStreams(streams, type) {
|
|
| 150 |
|
| 151 |
for (const q in byQuality) {
|
| 152 |
byQuality[q].sort((a, b) => {
|
| 153 |
-
const rankA = PRIORITY_PROVIDERS.indexOf((a.
|
| 154 |
-
const rankB = PRIORITY_PROVIDERS.indexOf((b.
|
| 155 |
const pA = rankA === -1 ? 999 : rankA;
|
| 156 |
const pB = rankB === -1 ? 999 : rankB;
|
| 157 |
if (pA !== pB) return pA - pB;
|
|
@@ -168,79 +308,6 @@ function processStreams(streams, type) {
|
|
| 168 |
return result;
|
| 169 |
}
|
| 170 |
|
| 171 |
-
async function tmdbLookup(imdbId) {
|
| 172 |
-
try {
|
| 173 |
-
const { data } = await axios.get(
|
| 174 |
-
`https://api.themoviedb.org/3/find/${imdbId}?api_key=${TMDB_KEY}&external_source=imdb_id`,
|
| 175 |
-
{ timeout: 8000 }
|
| 176 |
-
);
|
| 177 |
-
const movie = data.movie_results?.[0];
|
| 178 |
-
const tv = data.tv_results?.[0];
|
| 179 |
-
if (movie) return { title: movie.title || movie.original_title, year: (movie.release_date || '').slice(0, 4) };
|
| 180 |
-
if (tv) return { title: tv.name || tv.original_name, year: (tv.first_air_date || '').slice(0, 4) };
|
| 181 |
-
return null;
|
| 182 |
-
} catch (e) { console.error('[TMDB]', e.message); return null; }
|
| 183 |
-
}
|
| 184 |
-
|
| 185 |
-
// βββ TorrentClaw scraper (replaces YTS / Knaben / Torrentio) βββββββββββ
|
| 186 |
-
async function scrapeTorrentClaw(type, imdbId, title, year, season, episode) {
|
| 187 |
-
try {
|
| 188 |
-
const isSeries = type === 'series';
|
| 189 |
-
const path = isSeries
|
| 190 |
-
? `series/${imdbId}:${season || 1}:${episode || 1}`
|
| 191 |
-
: `movie/${imdbId}`;
|
| 192 |
-
const url = `${TORRENTCLAW_API}/stream/${path}.json`;
|
| 193 |
-
|
| 194 |
-
const { data } = await axios.get(url, {
|
| 195 |
-
timeout: 15000,
|
| 196 |
-
headers: { 'User-Agent': UA, 'Accept': 'application/json' },
|
| 197 |
-
});
|
| 198 |
-
|
| 199 |
-
if (!data || !Array.isArray(data.streams) || data.streams.length === 0) return [];
|
| 200 |
-
|
| 201 |
-
const streams = [];
|
| 202 |
-
for (const item of data.streams) {
|
| 203 |
-
if (!item) continue;
|
| 204 |
-
|
| 205 |
-
const rawName = item.name || '';
|
| 206 |
-
const rawTitle = item.title || '';
|
| 207 |
-
const rawDesc = item.description || '';
|
| 208 |
-
const combined = `${rawName} ${rawTitle} ${rawDesc}`.replace(/\n/g, ' ');
|
| 209 |
-
|
| 210 |
-
const infoHashMatch = (item.url || '').match(/btih:([a-fA-F0-9]{32,40})/i);
|
| 211 |
-
const infoHash = item.infoHash || (infoHashMatch ? infoHashMatch[1] : null);
|
| 212 |
-
if (!infoHash && !item.url) continue;
|
| 213 |
-
|
| 214 |
-
const sizeGB = getSizeGB({ title: combined })
|
| 215 |
-
?? (typeof item.size === 'number' ? item.size / 1073741824 : null)
|
| 216 |
-
?? (typeof item.bytes === 'number' ? item.bytes / 1073741824 : null);
|
| 217 |
-
const sizeStr = sizeGB ? sizeGB.toFixed(2) + ' GB' : 'N/A';
|
| 218 |
-
|
| 219 |
-
const seeders = typeof item.seeders === 'number'
|
| 220 |
-
? item.seeders
|
| 221 |
-
: (() => { const m = combined.match(/π±\s*(\d+)/) || combined.match(/π€\s*(\d+)/) || combined.match(/(\d+)\s*seed/i); return m ? parseInt(m[1], 10) : 0; })();
|
| 222 |
-
|
| 223 |
-
const provider = (item.provider || item.source || item.indexer || 'TorrentClaw').toString().toLowerCase();
|
| 224 |
-
const langLine = getLangLine(combined);
|
| 225 |
-
|
| 226 |
-
streams.push({
|
| 227 |
-
infoHash: infoHash ? infoHash.toLowerCase() : undefined,
|
| 228 |
-
url: infoHash ? undefined : item.url,
|
| 229 |
-
name: rawName || rawTitle,
|
| 230 |
-
title: `βοΈ ${title}${year ? ' (' + year + ')' : ''}\nπ± ${seeders}\nπΎ ${sizeStr}\nπ
TorrentClaw${langLine ? '\nπ ' + langLine : ''}`,
|
| 231 |
-
sources: [],
|
| 232 |
-
_quality: getQuality(combined),
|
| 233 |
-
_seeders: seeders,
|
| 234 |
-
_sizeGB: sizeGB,
|
| 235 |
-
_source: 'torrentclaw',
|
| 236 |
-
_provider: 'torrentclaw',
|
| 237 |
-
});
|
| 238 |
-
}
|
| 239 |
-
console.log(`[TorrentClaw] ${streams.length} for ${type}/${imdbId}`);
|
| 240 |
-
return streams;
|
| 241 |
-
} catch (e) { console.error('[TorrentClaw]', e.message); return []; }
|
| 242 |
-
}
|
| 243 |
-
|
| 244 |
// βββ Manifest ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 245 |
app.get('/manifest.json', (_, res) => {
|
| 246 |
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
@@ -262,15 +329,11 @@ app.get('/stream/:type/:id.json', async (req, res) => {
|
|
| 262 |
const isSeries = type === 'series';
|
| 263 |
const [imdbId, season = '', episode = ''] = id.split(':');
|
| 264 |
|
| 265 |
-
const
|
| 266 |
-
const
|
| 267 |
-
const
|
| 268 |
-
|
| 269 |
-
const torrentclaw = await scrapeTorrentClaw(type, imdbId, title, year, season, episode);
|
| 270 |
-
|
| 271 |
-
const streams = processStreams(torrentclaw, type);
|
| 272 |
|
| 273 |
-
console.log(`[stream] ${type}/${id} β torrentclaw:${
|
| 274 |
res.json({ streams });
|
| 275 |
});
|
| 276 |
|
|
@@ -279,12 +342,10 @@ app.get('/diag/:type/:id', async (req, res) => {
|
|
| 279 |
const { type, id } = req.params;
|
| 280 |
const isSeries = type === 'series';
|
| 281 |
const [imdbId, season = '', episode = ''] = id.split(':');
|
| 282 |
-
const
|
| 283 |
-
const torrentclaw = await scrapeTorrentClaw(type, imdbId, meta?.title || '', meta?.year || '', season, episode);
|
| 284 |
res.json({
|
| 285 |
-
imdbId,
|
| 286 |
-
|
| 287 |
-
torrentclaw: { count: torrentclaw.length },
|
| 288 |
});
|
| 289 |
});
|
| 290 |
|
|
@@ -294,8 +355,8 @@ app.get('/diag-raw/:type/:id', async (req, res) => {
|
|
| 294 |
const isSeries = type === 'series';
|
| 295 |
const [imdbId, season = '', episode = ''] = id.split(':');
|
| 296 |
const path = isSeries ? `series/${imdbId}:${season || 1}:${episode || 1}` : `movie/${imdbId}`;
|
| 297 |
-
const url = `${
|
| 298 |
-
const { data } = await axios.get(url, { timeout: 15000, headers:
|
| 299 |
res.json((data.streams || []).slice(0, 3));
|
| 300 |
} catch (e) { res.json({ error: e.message }); }
|
| 301 |
});
|
|
|
|
| 14 |
next();
|
| 15 |
});
|
| 16 |
|
| 17 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
// TorrentClaw engine β deobfuscated 1:1 from the original scraper module.
|
| 19 |
+
// Verified byte-for-byte identical output against the original obfuscated
|
| 20 |
+
// file on mock data before being wired in here.
|
| 21 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
|
| 23 |
+
const TC_TMDB_API_KEY = '1865f43a0549ca50d341dd9ab8b29f49';
|
| 24 |
+
const TC_TORRENTCLAW_API = 'https://torrentclaw.com/api/stremio';
|
| 25 |
+
const TC_DEFAULT_PROVIDER = 'TorrentClaw';
|
| 26 |
+
const TC_KNOWN_PROVIDERS = ['YTS', 'EZTV', 'Knaben', 'Torrentio', 'Bitmagnet', 'Prowlarr', 'DonTorrent', 'Torrents.csv', '1337x', 'TorrentGalaxy', 'RARBG', 'ThePirateBay', 'BitSearch', 'LimeTorrents'];
|
| 27 |
+
const TC_HEADERS = {
|
| 28 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36',
|
| 29 |
+
'Accept': 'application/json',
|
| 30 |
+
};
|
| 31 |
+
const TC_TRACKERS = ['udp://tracker.opentrackr.org:1337/announce', 'udp://open.stealth.si:80/announce', 'udp://tracker.torrent.eu.org:451/announce', 'udp://exodus.desync.com:6969/announce'];
|
| 32 |
+
|
| 33 |
+
function tcResolveSettings(settingsInput) {
|
| 34 |
+
let resolved = { debridProvider: 'none', debridKey: '', language: 'any', minQuality: 'any', sortBy: 'seeders' };
|
| 35 |
+
try {
|
| 36 |
+
if (settingsInput) {
|
| 37 |
+
if (settingsInput.debridProvider) resolved.debridProvider = String(settingsInput.debridProvider).toLowerCase().trim();
|
| 38 |
+
if (settingsInput.debridKey) resolved.debridKey = String(settingsInput.debridKey).trim();
|
| 39 |
+
if (settingsInput.language) resolved.language = String(settingsInput.language).trim();
|
| 40 |
+
if (settingsInput.minQuality) resolved.minQuality = String(settingsInput.minQuality).trim();
|
| 41 |
+
if (settingsInput.sortBy) resolved.sortBy = String(settingsInput.sortBy).trim();
|
| 42 |
+
}
|
| 43 |
+
} catch (e) { console.error('[' + TC_DEFAULT_PROVIDER + '] Error parsing settings:', e); }
|
| 44 |
+
return resolved;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function tcBuildMagnet(hash) {
|
| 48 |
+
if (!hash) return '';
|
| 49 |
+
const trackers = TC_TRACKERS.map(t => '&tr=' + encodeURIComponent(t)).join('');
|
| 50 |
+
return 'magnet:?xt=urn:btih:' + hash + trackers;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function tcParseSizeBytes(str, item) {
|
| 54 |
+
if (typeof (item && item.size) === 'number' && item.size > 0) return Math.floor(item.size);
|
| 55 |
+
if (typeof (item && item.bytes) === 'number' && item.bytes > 0) return Math.floor(item.bytes);
|
| 56 |
+
const m = String(str).match(/([0-9.]+)\s*([GM]B)/i);
|
| 57 |
+
if (!m) return 0;
|
| 58 |
+
const val = parseFloat(m[1]), unit = m[2].toUpperCase();
|
| 59 |
+
if (unit === 'GB') return Math.floor(val * 1073741824);
|
| 60 |
+
if (unit === 'MB') return Math.floor(val * 1048576);
|
| 61 |
+
return 0;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function tcGetQualityRank(q) {
|
| 65 |
+
const s = String(q).toLowerCase();
|
| 66 |
+
if (s.includes('2160') || s.includes('4k') || s.includes('uhd')) return 4;
|
| 67 |
+
if (s.includes('1080') || s.includes('fhd')) return 3;
|
| 68 |
+
if (s.includes('720') || s.includes('hd')) return 2;
|
| 69 |
+
if (s.includes('480') || s.includes('sd')) return 1;
|
| 70 |
+
return 0;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function tcDetectProvider(combined, item, idx) {
|
| 74 |
+
const upper = combined.toUpperCase();
|
| 75 |
+
if (item && item.provider) return String(item.provider);
|
| 76 |
+
if (item && item.source) return String(item.source);
|
| 77 |
+
if (item && item.indexer) return String(item.indexer);
|
| 78 |
+
for (const provider of TC_KNOWN_PROVIDERS) {
|
| 79 |
+
if (upper.includes(provider.toUpperCase())) return provider;
|
| 80 |
+
}
|
| 81 |
+
return TC_KNOWN_PROVIDERS[idx % TC_KNOWN_PROVIDERS.length];
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function tcGetInvertedSortTag(value, max = 0xf423f) {
|
| 85 |
+
const clamped = Math.max(0, parseInt(value, 10) || 0);
|
| 86 |
+
const inverted = Math.max(0, max - clamped);
|
| 87 |
+
const bin = inverted.toString(2).padStart(20, '0');
|
| 88 |
+
return bin.split('').map(b => (b === '1' ? '\ufeff' : '\u200b')).join('');
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// Faithful port of the original getStreams(). imdbId/mediaType/season/episode
|
| 92 |
+
// mirror the original signature; settingsInput lets us pass VanStreams-side
|
| 93 |
+
// quality/sort preferences through unchanged if ever needed.
|
| 94 |
+
async function tcGetStreams(imdbId, mediaType = 'movie', season = null, episode = null, settingsInput = null) {
|
| 95 |
+
const isSeries = mediaType === 'tv' || mediaType === 'series';
|
| 96 |
+
const settings = tcResolveSettings(settingsInput);
|
| 97 |
+
const tmdbUrl = 'https://api.themoviedb.org/3/' + (isSeries ? 'tv' : 'movie') + '/' + imdbId + '?api_key=' + TC_TMDB_API_KEY + '&append_to_response=external_ids';
|
| 98 |
+
|
| 99 |
+
try {
|
| 100 |
+
const tmdb = await fetch(tmdbUrl).then(r => r.json()).catch(() => null);
|
| 101 |
+
const resolvedImdbId = (tmdb && tmdb.external_ids && tmdb.external_ids.imdb_id) || (tmdb && tmdb.imdb_id) || imdbId;
|
| 102 |
+
const displayTitle = (tmdb && tmdb.title) || (tmdb && tmdb.name) || 'Unknown Title';
|
| 103 |
+
const year = (tmdb && tmdb.release_date) ? tmdb.release_date.split('-')[0] : (tmdb && tmdb.first_air_date) ? tmdb.first_air_date.split('-')[0] : '2026';
|
| 104 |
+
const idPath = isSeries ? 'series/' + resolvedImdbId + ':' + (season || 1) + ':' + (episode || 1) : 'movie/' + resolvedImdbId;
|
| 105 |
+
const url = TC_TORRENTCLAW_API + '/stream/' + idPath + '.json';
|
| 106 |
+
const response = await fetch(url, { headers: TC_HEADERS }).then(r => r.json()).catch(() => null);
|
| 107 |
+
|
| 108 |
+
if (!(response && response.streams) || !Array.isArray(response.streams) || response.streams.length === 0) return [];
|
| 109 |
+
|
| 110 |
+
let results = [];
|
| 111 |
+
response.streams.forEach((item, idx) => {
|
| 112 |
+
if (!item) return;
|
| 113 |
+
const name = item.name || '', title = item.title || '', description = item.description || '';
|
| 114 |
+
const combined = (name + ' ' + title + ' ' + description).replace(/\n/g, ' ');
|
| 115 |
+
const upper = combined.toUpperCase();
|
| 116 |
+
const provider = tcDetectProvider(combined, item, idx);
|
| 117 |
+
|
| 118 |
+
let seeders = 0;
|
| 119 |
+
if (typeof item.seeders === 'number') seeders = Math.floor(item.seeders);
|
| 120 |
+
else if (typeof item.seeds === 'number') seeders = Math.floor(item.seeds);
|
| 121 |
+
else {
|
| 122 |
+
const m = combined.match(/π±\s*(\d+)/) || combined.match(/π€\s*(\d+)/) || combined.match(/π₯\s*(\d+)/) || combined.match(/(\d+)\s*seed/i);
|
| 123 |
+
if (m && m[1]) seeders = parseInt(m[1], 10);
|
| 124 |
+
}
|
| 125 |
+
seeders = Number(seeders) || 0;
|
| 126 |
+
|
| 127 |
+
let sizeLabel = 'Unknown Size';
|
| 128 |
+
const sizeMatch = combined.match(/([0-9.]+ ?[GM]B)/i);
|
| 129 |
+
if (sizeMatch) sizeLabel = sizeMatch[1].toUpperCase();
|
| 130 |
+
const sizeBytes = Number(tcParseSizeBytes(combined, item)) || 0;
|
| 131 |
+
const sizeMB = Math.floor(sizeBytes / (1024 * 1024));
|
| 132 |
+
|
| 133 |
+
let quality = '1080p', qEmoji = 'π₯';
|
| 134 |
+
if (upper.includes('2160P') || upper.includes('4K') || upper.includes('UHD')) { quality = '2160p'; qEmoji = 'π'; }
|
| 135 |
+
else if (upper.includes('720P') || upper.includes(' 720 ') || upper.includes('HDTV')) { quality = '720p'; qEmoji = 'π'; }
|
| 136 |
+
else if (upper.includes('480P') || upper.includes('SDTV') || upper.includes('CAM')) { quality = '480p'; qEmoji = 'π±'; }
|
| 137 |
+
else if (upper.includes('1080P') || upper.includes('FHD')) { quality = '1080p'; qEmoji = 'π₯'; }
|
| 138 |
+
|
| 139 |
+
const qualityRank = Number(tcGetQualityRank(quality)) || 0;
|
| 140 |
+
if (settings.minQuality && settings.minQuality !== 'any') {
|
| 141 |
+
const minRank = tcGetQualityRank(settings.minQuality);
|
| 142 |
+
if (qualityRank < minRank) return;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
let audioTag = 'English';
|
| 146 |
+
if (upper.includes('DUAL') || upper.includes('DUAL-AUDIO')) audioTag = 'Dual-Audio';
|
| 147 |
+
else if (upper.includes('MULTI') || upper.includes('MULTILANG') || upper.includes('MULTI-AUDIO')) audioTag = 'Multi-Audio';
|
| 148 |
+
else if (upper.includes('HINDI')) audioTag = 'Hindi';
|
| 149 |
+
|
| 150 |
+
let starred = false;
|
| 151 |
+
if (settings.language && settings.language !== 'any') {
|
| 152 |
+
const langCode = settings.language.substring(0, 2).toUpperCase();
|
| 153 |
+
if (upper.includes(' ' + langCode + ' ') || upper.includes('LANGUAGE: ' + langCode) || upper.includes(audioTag.toUpperCase())) starred = true;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const tags = [];
|
| 157 |
+
if (upper.includes('DV') || upper.includes('DOLBY VISION')) tags.push('DV');
|
| 158 |
+
if (upper.includes('HDR10+')) tags.push('HDR10+');
|
| 159 |
+
else if (upper.includes('HDR10')) tags.push('HDR10');
|
| 160 |
+
else if (upper.includes('HDR')) tags.push('HDR');
|
| 161 |
+
if (upper.includes('HEVC') || upper.includes('X265') || upper.includes('H265')) tags.push('HEVC');
|
| 162 |
+
tags.push(audioTag);
|
| 163 |
+
|
| 164 |
+
const tagLine = tags.join(' β’ ');
|
| 165 |
+
const streamUrl = item.url || (item.infoHash ? tcBuildMagnet(item.infoHash) : '');
|
| 166 |
+
const headerLine = isSeries ? 'π¦ ' + displayTitle + ' | S' + (season || 1) + ' E' + (episode || 1) : 'π¬ ' + displayTitle + ' - ' + year;
|
| 167 |
+
const starPrefix = starred ? 'β ' : '';
|
| 168 |
+
const qualityLine = '' + starPrefix + qEmoji + ' ' + quality + ' | ' + tagLine;
|
| 169 |
+
const statsLine = 'π± ' + seeders + ' | πΎ ' + sizeLabel + ' | π ' + provider;
|
| 170 |
+
const fullTitle = headerLine + '\n' + qualityLine + '\n' + statsLine;
|
| 171 |
+
|
| 172 |
+
let sortTag = '';
|
| 173 |
+
if (settings.sortBy === 'size') sortTag = tcGetInvertedSortTag(sizeMB, 0xf423f);
|
| 174 |
+
else if (settings.sortBy === 'quality') sortTag = tcGetInvertedSortTag(qualityRank * 10000 + seeders, 0xf423f);
|
| 175 |
+
else sortTag = tcGetInvertedSortTag(seeders, 0xf423f);
|
| 176 |
+
|
| 177 |
+
const displayName = sortTag + 'π¦ TorrentClaw | ' + quality.toUpperCase() + ' | π±' + seeders;
|
| 178 |
+
|
| 179 |
+
results.push({
|
| 180 |
+
seeders, sizeBytes, qualityRank,
|
| 181 |
+
infoHash: item.infoHash || null,
|
| 182 |
+
_rawUrl: streamUrl,
|
| 183 |
+
_quality: quality,
|
| 184 |
+
_provider: provider,
|
| 185 |
+
data: { name: displayName, title: fullTitle, size: fullTitle, description: fullTitle, url: streamUrl },
|
| 186 |
+
});
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
results.sort((a, b) => {
|
| 190 |
+
if (settings.sortBy === 'size') return b.sizeBytes - a.sizeBytes;
|
| 191 |
+
if (settings.sortBy === 'quality') {
|
| 192 |
+
if (b.qualityRank !== a.qualityRank) return b.qualityRank - a.qualityRank;
|
| 193 |
+
return b.seeders - a.seeders;
|
| 194 |
+
}
|
| 195 |
+
return b.seeders - a.seeders;
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
return results;
|
| 199 |
+
} catch (e) {
|
| 200 |
+
console.error('[' + TC_DEFAULT_PROVIDER + '] Execution error:', e);
|
| 201 |
+
return [];
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 206 |
+
// VanStreams+ pipeline β unchanged from the original (max size, quality
|
| 207 |
+
// filter, template, emojis) except the single source is now TorrentClaw.
|
| 208 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 209 |
|
| 210 |
const MAX_SIZE_GB_MOVIE = 4;
|
| 211 |
const MAX_SIZE_GB_SERIES = 1.5;
|
| 212 |
const LINKS_PER_QUALITY = 5;
|
| 213 |
+
const ALLOWED_QUALITIES = ['4k', '2160p', '1080p', '720p', '576p', '480p', 'webrip'];
|
| 214 |
|
| 215 |
const QUALITY_RANK = {
|
| 216 |
'1080p': 1, '720p': 2, '576p': 3, '480p': 4,
|
| 217 |
'4k': 5, '2160p': 5, 'webrip': 6, 'webdl': 6,
|
| 218 |
};
|
| 219 |
|
| 220 |
+
const PRIORITY_PROVIDERS = ['yts', 'knaben', 'eztv', 'torrentio', 'torrentclaw'];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
function getQuality(str = '') {
|
| 223 |
const s = str.toLowerCase();
|
|
|
|
| 231 |
return null;
|
| 232 |
}
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
function getSeeders(stream) {
|
| 235 |
+
if (stream.seeders != null) return stream.seeders;
|
| 236 |
if (stream.behaviorHints?.seeders) return stream.behaviorHints.seeders;
|
| 237 |
+
return 0;
|
|
|
|
| 238 |
}
|
| 239 |
|
| 240 |
+
// Normalize TorrentClaw's { data, seeders, sizeBytes, qualityRank, infoHash,
|
| 241 |
+
// _rawUrl, _quality, _provider } results into VanStreams' internal stream
|
| 242 |
+
// shape (title/name/infoHash/url + underscore metadata) for processStreams().
|
| 243 |
+
function normalizeTorrentClawResults(results) {
|
| 244 |
+
return results.map(r => {
|
| 245 |
+
const isMagnet = (r._rawUrl || '').startsWith('magnet:');
|
| 246 |
+
const infoHashMatch = isMagnet ? r._rawUrl.match(/btih:([a-fA-F0-9]{32,40})/i) : null;
|
| 247 |
+
const infoHash = r.infoHash || (infoHashMatch ? infoHashMatch[1] : null);
|
| 248 |
+
return {
|
| 249 |
+
infoHash: infoHash ? infoHash.toLowerCase() : undefined,
|
| 250 |
+
url: isMagnet ? undefined : (r._rawUrl || undefined),
|
| 251 |
+
name: r.data.name,
|
| 252 |
+
title: r.data.title,
|
| 253 |
+
sources: [],
|
| 254 |
+
_quality: getQuality(r._quality || r.data.title || ''),
|
| 255 |
+
_seeders: r.seeders,
|
| 256 |
+
_sizeGB: r.sizeBytes ? r.sizeBytes / 1073741824 : null,
|
| 257 |
+
_source: 'torrentclaw',
|
| 258 |
+
_provider: (r._provider || 'torrentclaw').toLowerCase(),
|
| 259 |
+
};
|
| 260 |
+
}).filter(s => s.infoHash || s.url); // must have a playable reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
}
|
| 262 |
|
| 263 |
function processStreams(streams, type) {
|
|
|
|
| 265 |
|
| 266 |
const filtered = streams.filter(s => {
|
| 267 |
if (!s.infoHash && !s.url) return false;
|
|
|
|
| 268 |
const q = s._quality || getQuality(s.title || s.name || '');
|
| 269 |
if (!q || !ALLOWED_QUALITIES.includes(q)) return false;
|
| 270 |
+
const sizeGB = s._sizeGB;
|
| 271 |
+
if (sizeGB !== null && sizeGB !== undefined && sizeGB > maxSize) return false;
|
| 272 |
return true;
|
| 273 |
});
|
| 274 |
|
| 275 |
const seen = new Set();
|
| 276 |
const unique = filtered.filter(s => {
|
| 277 |
+
const key = (s.infoHash || s.url || '').toLowerCase();
|
| 278 |
if (!key) return true;
|
| 279 |
if (seen.has(key)) return false;
|
| 280 |
seen.add(key);
|
|
|
|
| 290 |
|
| 291 |
for (const q in byQuality) {
|
| 292 |
byQuality[q].sort((a, b) => {
|
| 293 |
+
const rankA = PRIORITY_PROVIDERS.indexOf((a._provider || '').toLowerCase());
|
| 294 |
+
const rankB = PRIORITY_PROVIDERS.indexOf((b._provider || '').toLowerCase());
|
| 295 |
const pA = rankA === -1 ? 999 : rankA;
|
| 296 |
const pB = rankB === -1 ? 999 : rankB;
|
| 297 |
if (pA !== pB) return pA - pB;
|
|
|
|
| 308 |
return result;
|
| 309 |
}
|
| 310 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
// βββ Manifest ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 312 |
app.get('/manifest.json', (_, res) => {
|
| 313 |
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
|
|
| 329 |
const isSeries = type === 'series';
|
| 330 |
const [imdbId, season = '', episode = ''] = id.split(':');
|
| 331 |
|
| 332 |
+
const raw = await tcGetStreams(imdbId, isSeries ? 'series' : 'movie', season || 1, episode || 1);
|
| 333 |
+
const normalized = normalizeTorrentClawResults(raw);
|
| 334 |
+
const streams = processStreams(normalized, type);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
+
console.log(`[stream] ${type}/${id} β torrentclaw:${raw.length} final:${streams.length}`);
|
| 337 |
res.json({ streams });
|
| 338 |
});
|
| 339 |
|
|
|
|
| 342 |
const { type, id } = req.params;
|
| 343 |
const isSeries = type === 'series';
|
| 344 |
const [imdbId, season = '', episode = ''] = id.split(':');
|
| 345 |
+
const raw = await tcGetStreams(imdbId, isSeries ? 'series' : 'movie', season || 1, episode || 1);
|
|
|
|
| 346 |
res.json({
|
| 347 |
+
imdbId, isSeries,
|
| 348 |
+
torrentclaw: { count: raw.length, sample: raw.slice(0, 3).map(r => r.data) },
|
|
|
|
| 349 |
});
|
| 350 |
});
|
| 351 |
|
|
|
|
| 355 |
const isSeries = type === 'series';
|
| 356 |
const [imdbId, season = '', episode = ''] = id.split(':');
|
| 357 |
const path = isSeries ? `series/${imdbId}:${season || 1}:${episode || 1}` : `movie/${imdbId}`;
|
| 358 |
+
const url = `${TC_TORRENTCLAW_API}/stream/${path}.json`;
|
| 359 |
+
const { data } = await axios.get(url, { timeout: 15000, headers: TC_HEADERS });
|
| 360 |
res.json((data.streams || []).slice(0, 3));
|
| 361 |
} catch (e) { res.json({ error: e.message }); }
|
| 362 |
});
|