vanseleem commited on
Commit
e14f87b
Β·
verified Β·
1 Parent(s): bd52c99

Update index.js

Browse files
Files changed (1) hide show
  1. index.js +164 -120
index.js CHANGED
@@ -17,7 +17,11 @@ app.use((req, res, next) => {
17
  const TMDB_KEY = '83d364331c40bfbe29858aeed82f45cc';
18
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
19
  const TORRENTCLAW_API = 'https://torrentclaw.com/api/stremio';
20
- const TORRENTIO_API = 'https://torrentio.strem.fun';
 
 
 
 
21
 
22
  const MAX_SIZE_GB_MOVIE = 4;
23
  const MAX_SIZE_GB_SERIES = 1.5;
@@ -29,25 +33,9 @@ const QUALITY_RANK = {
29
  '4k': 5, '2160p': 5, 'webrip': 6, 'webdl': 6,
30
  };
31
 
32
- const PRIORITY_PROVIDERS = ['yts', 'knaben', 'torrentsdb', 'eztv', 'nyaasi', 'thepiratebay', 'torrentclaw', 'torrentio'];
33
-
34
- const ALLOWED_PROVIDERS = [
35
- 'yts',
36
- 'knaben',
37
- 'thepiratesbay',
38
- 'thepiratebay',
39
- 'eztv',
40
- 'torrentcsv',
41
- 'nyaa',
42
- 'nyaasi',
43
- 'limetorrent',
44
- 'kickasstorrents',
45
- 'animetosho',
46
- 'tokyotosho',
47
- 'torrentclaw',
48
- 'torrentio',
49
- 'unknown'
50
- ];
51
 
52
  const TR = [
53
  'udp://tracker.opentrackr.org:1337/announce',
@@ -66,6 +54,27 @@ function buildMagnet(hash, name) {
66
  return 'magnet:?xt=urn:btih:' + hash.toLowerCase() + '&dn=' + encodeURIComponent(name) + TR;
67
  }
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  function getQuality(str = '') {
70
  const s = str.toLowerCase();
71
  if (s.includes('4k') || s.includes('2160p') || s.includes('uhd')) return '4k';
@@ -89,7 +98,7 @@ function getSizeGB(stream) {
89
  function getSeeders(stream) {
90
  if (stream._seeders != null) return stream._seeders;
91
  if (stream.behaviorHints?.seeders) return stream.behaviorHints.seeders;
92
- const m = (stream.title || '').match(/πŸ‘€\s*(\d+)/);
93
  return m ? parseInt(m[1], 10) : 0;
94
  }
95
 
@@ -140,12 +149,44 @@ function getLangLine(str = '') {
140
  return [...new Set(found)].join(' / ');
141
  }
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  function processStreams(streams, type) {
144
  const maxSize = type === 'series' ? MAX_SIZE_GB_SERIES : MAX_SIZE_GB_MOVIE;
145
 
146
  const filtered = streams.filter(s => {
147
  if (!s.infoHash && !s.url) return false;
148
- if (s._provider && !ALLOWED_PROVIDERS.includes(s._provider.toLowerCase())) return false;
149
  const q = s._quality || getQuality(s.title || s.name || '');
150
  if (!q || !ALLOWED_QUALITIES.includes(q)) return false;
151
  const sizeGB = s._sizeGB ?? getSizeGB(s);
@@ -203,7 +244,7 @@ async function tmdbLookup(imdbId) {
203
  } catch (e) { console.error('[TMDB]', e.message); return null; }
204
  }
205
 
206
- // ─── TorrentClaw scraper ───────────────────────────────────────────────
207
  async function scrapeTorrentClaw(type, imdbId, title, year, season, episode) {
208
  try {
209
  const isSeries = type === 'series';
@@ -227,6 +268,7 @@ async function scrapeTorrentClaw(type, imdbId, title, year, season, episode) {
227
  const rawTitle = item.title || '';
228
  const rawDesc = item.description || '';
229
  const combined = `${rawName} ${rawTitle} ${rawDesc}`.replace(/\n/g, ' ');
 
230
 
231
  const infoHashMatch = (item.url || '').match(/btih:([a-fA-F0-9]{32,40})/i);
232
  const infoHash = item.infoHash || (infoHashMatch ? infoHashMatch[1] : null);
@@ -235,22 +277,40 @@ async function scrapeTorrentClaw(type, imdbId, title, year, season, episode) {
235
  const sizeGB = getSizeGB({ title: combined })
236
  ?? (typeof item.size === 'number' ? item.size / 1073741824 : null)
237
  ?? (typeof item.bytes === 'number' ? item.bytes / 1073741824 : null);
238
- const sizeStr = sizeGB ? sizeGB.toFixed(2) + ' GB' : 'N/A';
239
 
240
  const seeders = typeof item.seeders === 'number'
241
  ? item.seeders
242
  : (() => { const m = combined.match(/🌱\s*(\d+)/) || combined.match(/πŸ‘€\s*(\d+)/) || combined.match(/(\d+)\s*seed/i); return m ? parseInt(m[1], 10) : 0; })();
243
 
244
- const provider = (item.provider || item.source || item.indexer || 'TorrentClaw').toString().toLowerCase();
 
 
 
 
 
 
 
 
 
245
  const langLine = getLangLine(combined);
 
 
 
 
 
 
 
 
 
246
 
247
  streams.push({
248
  infoHash: infoHash ? infoHash.toLowerCase() : undefined,
249
  url: infoHash ? undefined : item.url,
250
- name: rawName || rawTitle,
251
- title: `β˜€οΈ ${title}${year ? ' (' + year + ')' : ''}\n🌱 ${seeders}\nπŸ’Ύ ${sizeStr}\nπŸ… TorrentClaw${langLine ? '\nπŸ”Š ' + langLine : ''}`,
252
  sources: [],
253
- _quality: getQuality(combined),
254
  _seeders: seeders,
255
  _sizeGB: sizeGB,
256
  _source: 'torrentclaw',
@@ -262,80 +322,58 @@ async function scrapeTorrentClaw(type, imdbId, title, year, season, episode) {
262
  } catch (e) { console.error('[TorrentClaw]', e.message); return []; }
263
  }
264
 
265
- // ─── Torrentio scraper ────────────────────────────────────────────────
266
- async function scrapeTorrentio(type, imdbId, title, year, season, episode) {
267
  try {
268
- const isSeries = type === 'series';
269
- const path = isSeries
270
- ? `series/${imdbId}:${season || 1}:${episode || 1}`
271
- : `movie/${imdbId}`;
272
- const url = `${TORRENTIO_API}/stream/${path}.json`;
273
-
274
- const { data } = await axios.get(url, {
275
- timeout: 15000,
276
- headers: { 'User-Agent': UA, 'Accept': 'application/json' },
277
- });
278
-
279
- if (!data || !Array.isArray(data.streams) || data.streams.length === 0) return [];
280
-
281
- const streams = [];
282
- for (const item of data.streams) {
283
- if (!item) continue;
284
-
285
- const rawName = item.name || '';
286
- const rawTitle = item.title || '';
287
- const combined = `${rawName} ${rawTitle}`.replace(/\n/g, ' ');
288
-
289
- const infoHashMatch = (item.url || '').match(/btih:([a-fA-F0-9]{32,40})/i);
290
- const infoHash = item.infoHash || (infoHashMatch ? infoHashMatch[1] : null);
291
- if (!infoHash && !item.url) continue;
292
-
293
- // Extract size
294
- let sizeGB = null;
295
- let sizeStr = 'N/A';
296
- const sizeMatch = combined.match(/([0-9.]+ ?[GM]B)/i);
297
- if (sizeMatch) {
298
- const val = parseFloat(sizeMatch[1]);
299
- sizeGB = sizeMatch[2].toUpperCase() === 'GB' ? val : val / 1024;
300
- sizeStr = sizeGB.toFixed(2) + ' GB';
301
- }
302
-
303
- // Extract seeders
304
- const seedersMatch = combined.match(/πŸ‘€\s*(\d+)/);
305
- const seeders = seedersMatch ? parseInt(seedersMatch[1], 10) : 0;
306
-
307
- // Extract provider (matching Script 2 logic)
308
- let provider = 'torrentio';
309
- const providerMatch = combined.match(/βš™οΈ\s*([^πŸ‘€πŸ’Ύ\n]+)/);
310
- if (providerMatch) {
311
- provider = providerMatch[1].trim();
312
- } else {
313
- if (combined.includes('RARBG')) provider = 'RARBG';
314
- else if (combined.includes('YTS')) provider = 'YTS';
315
- else if (combined.includes('TPB') || combined.includes('ThePirateBay')) provider = 'ThePirateBay';
316
- else if (combined.includes('1337X')) provider = '1337x';
317
- else if (combined.includes('EZTV')) provider = 'EZTV';
318
- else if (combined.includes('TGX')) provider = 'TGX';
319
- }
320
- provider = provider.toLowerCase();
321
-
322
- const quality = getQuality(combined);
323
- const langLine = getLangLine(combined);
324
 
325
- streams.push({
326
- infoHash: infoHash ? infoHash.toLowerCase() : undefined,
327
- url: infoHash ? undefined : item.url,
328
- name: rawName || 'Torrentio',
329
- title: `β˜€οΈ ${title}${year ? ' (' + year + ')' : ''}\n🌱 ${seeders}\nπŸ’Ύ ${sizeStr}\nπŸ… ${provider}${langLine ? '\nπŸ”Š ' + langLine : ''}`,
330
- sources: [],
331
  _quality: quality,
332
  _seeders: seeders,
333
  _sizeGB: sizeGB,
334
- _source: provider,
335
  _provider: provider,
336
- });
337
- }
338
- console.log(`[Torrentio] ${streams.length} for ${type}/${imdbId}`);
339
  return streams;
340
  } catch (e) { console.error('[Torrentio]', e.message); return []; }
341
  }
@@ -358,59 +396,65 @@ app.get('/manifest.json', (_, res) => {
358
  // ─── Stream handler ────────────────────────────────────────────────
359
  app.get('/stream/:type/:id.json', async (req, res) => {
360
  const { type, id } = req.params;
 
361
  const [imdbId, season = '', episode = ''] = id.split(':');
362
 
363
  const meta = await tmdbLookup(imdbId);
364
  const title = meta?.title || '';
365
  const year = meta?.year || '';
366
 
367
- // Fetch from both providers in parallel
368
- const [torrentclaw, torrentio] = await Promise.all([
369
  scrapeTorrentClaw(type, imdbId, title, year, season, episode),
370
- scrapeTorrentio(type, imdbId, title, year, season, episode)
371
  ]);
372
 
373
- const combinedStreams = [...torrentclaw, ...torrentio];
374
- const streams = processStreams(combinedStreams, type);
375
 
376
- console.log(`[stream] ${type}/${id} β†’ torrentclaw:${torrentclaw.length} torrentio:${torrentio.length} combined:${combinedStreams.length} final:${streams.length}`);
 
 
377
  res.json({ streams });
378
  });
379
 
380
  // ─── Diagnostic endpoints ─────────────────────────────────────────────
381
  app.get('/diag/:type/:id', async (req, res) => {
382
  const { type, id } = req.params;
 
383
  const [imdbId, season = '', episode = ''] = id.split(':');
384
  const meta = await tmdbLookup(imdbId);
385
-
386
- const [torrentclaw, torrentio] = await Promise.all([
387
  scrapeTorrentClaw(type, imdbId, meta?.title || '', meta?.year || '', season, episode),
388
- scrapeTorrentio(type, imdbId, meta?.title || '', meta?.year || '', season, episode)
389
  ]);
390
-
391
  res.json({
392
- imdbId, title: meta?.title, year: meta?.year, isSeries: type === 'series',
393
  tmdb: meta ? `OK: ${meta.title} (${meta.year})` : 'FAILED',
394
- torrentclaw: { count: torrentclaw.length },
395
- torrentio: { count: torrentio.length }
396
  });
397
  });
398
 
399
- app.get('/diag-raw/:type/:id', async (req, res) => {
400
  const { type, id } = req.params;
401
  try {
 
402
  const [imdbId, season = '', episode = ''] = id.split(':');
403
- const path = type === 'series' ? `series/${imdbId}:${season || 1}:${episode || 1}` : `movie/${imdbId}`;
404
-
405
- const [clawRes, tioRes] = await Promise.all([
406
- axios.get(`${TORRENTCLAW_API}/stream/${path}.json`, { timeout: 15000, headers: { 'User-Agent': UA, 'Accept': 'application/json' } }),
407
- axios.get(`${TORRENTIO_API}/stream/${path}.json`, { timeout: 15000, headers: { 'User-Agent': UA, 'Accept': 'application/json' } })
408
- ]);
409
 
410
- res.json({
411
- torrentclaw: (clawRes.data?.streams || []).slice(0, 3),
412
- torrentio: (tioRes.data?.streams || []).slice(0, 3)
413
- });
 
 
 
 
414
  } catch (e) { res.json({ error: e.message }); }
415
  });
416
 
 
17
  const TMDB_KEY = '83d364331c40bfbe29858aeed82f45cc';
18
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
19
  const TORRENTCLAW_API = 'https://torrentclaw.com/api/stremio';
20
+ const TORRENTIO_BASE = 'https://torrentio.strem.fun';
21
+
22
+ const PROXY_1 = 'https://corsproxy.io/?url=';
23
+ const PROXY_2 = 'https://api.codetabs.com/v1/proxy?quest=';
24
+ const PROXY_3 = 'https://thingproxy.freeboard.io/fetch/';
25
 
26
  const MAX_SIZE_GB_MOVIE = 4;
27
  const MAX_SIZE_GB_SERIES = 1.5;
 
33
  '4k': 5, '2160p': 5, 'webrip': 6, 'webdl': 6,
34
  };
35
 
36
+ const PRIORITY_PROVIDERS = ['yts', 'knaben', 'torrentsdb', 'eztv', 'nyaasi', 'thepiratebay', 'torrentclaw'];
37
+
38
+ // NOTE: ALLOWED_PROVIDERS removed on request β€” no provider is filtered out.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
  const TR = [
41
  'udp://tracker.opentrackr.org:1337/announce',
 
54
  return 'magnet:?xt=urn:btih:' + hash.toLowerCase() + '&dn=' + encodeURIComponent(name) + TR;
55
  }
56
 
57
+ // ─── Proxy fallback (used by Torrentio, same as the original script) ───
58
+ async function fetchWithProxy(url, options = {}) {
59
+ const proxies = [
60
+ url,
61
+ PROXY_1 + encodeURIComponent(url),
62
+ PROXY_2 + encodeURIComponent(url),
63
+ PROXY_3 + encodeURIComponent(url),
64
+ ];
65
+
66
+ for (const proxyUrl of proxies) {
67
+ try {
68
+ const response = await axios.get(proxyUrl, { timeout: 12000, headers: { 'User-Agent': UA }, ...options });
69
+ return response;
70
+ } catch (e) {
71
+ console.warn(`[Proxy] Failed: ${proxyUrl.substring(0, 50)}... Error: ${e.message}`);
72
+ continue;
73
+ }
74
+ }
75
+ throw new Error('All proxy attempts failed');
76
+ }
77
+
78
  function getQuality(str = '') {
79
  const s = str.toLowerCase();
80
  if (s.includes('4k') || s.includes('2160p') || s.includes('uhd')) return '4k';
 
98
  function getSeeders(stream) {
99
  if (stream._seeders != null) return stream._seeders;
100
  if (stream.behaviorHints?.seeders) return stream.behaviorHints.seeders;
101
+ const m = (stream.title || '').match(/🌱\s*(\d+)/) || (stream.title || '').match(/πŸ‘€\s*(\d+)/);
102
  return m ? parseInt(m[1], 10) : 0;
103
  }
104
 
 
149
  return [...new Set(found)].join(' / ');
150
  }
151
 
152
+ // ─── Unified TorrentClaw-style title template ──────────────────────────
153
+ // Used by BOTH TorrentClaw and Torrentio so every stream in the list looks
154
+ // consistent, matching the original TorrentClaw addon's format:
155
+ // 🎦 Title | S1 E1 (series) / 🎬 Title - 2026 (movie)
156
+ // πŸ”₯ 1080p | HDR10 β€’ HEVC β€’ English
157
+ // 🌱 42 | πŸ’Ύ 1.2 GB | πŸ”— YTS
158
+ function qualityEmoji(quality) {
159
+ switch (quality) {
160
+ case '4k':
161
+ case '2160p': return '🌟';
162
+ case '1080p': return 'πŸ”₯';
163
+ case '720p': return 'πŸ’Ž';
164
+ case '576p':
165
+ case '480p': return 'πŸ“±';
166
+ case 'webrip':
167
+ case 'webdl': return '🌐';
168
+ default: return 'πŸ”₯';
169
+ }
170
+ }
171
+
172
+ function buildUnifiedTitle({ isSeries, title, year, season, episode, quality, tagLine, seeders, sizeStr, provider }) {
173
+ const header = isSeries
174
+ ? `🎦 ${title} | S${season || 1} E${episode || 1}`
175
+ : `🎬 ${title}${year ? ' - ' + year : ''}`;
176
+ const qLine = `${qualityEmoji(quality)} ${quality || 'unknown'}${tagLine ? ' | ' + tagLine : ''}`;
177
+ const statsLine = `🌱 ${seeders} | πŸ’Ύ ${sizeStr} | πŸ”— ${provider}`;
178
+ return `${header}\n${qLine}\n${statsLine}`;
179
+ }
180
+
181
+ function buildUnifiedName({ quality, seeders, sourceLabel }) {
182
+ return `${sourceLabel} | ${(quality || 'unknown').toUpperCase()} | 🌱${seeders}`;
183
+ }
184
+
185
  function processStreams(streams, type) {
186
  const maxSize = type === 'series' ? MAX_SIZE_GB_SERIES : MAX_SIZE_GB_MOVIE;
187
 
188
  const filtered = streams.filter(s => {
189
  if (!s.infoHash && !s.url) return false;
 
190
  const q = s._quality || getQuality(s.title || s.name || '');
191
  if (!q || !ALLOWED_QUALITIES.includes(q)) return false;
192
  const sizeGB = s._sizeGB ?? getSizeGB(s);
 
244
  } catch (e) { console.error('[TMDB]', e.message); return null; }
245
  }
246
 
247
+ // ─── TorrentClaw scraper ─────────────────────────────────────────────
248
  async function scrapeTorrentClaw(type, imdbId, title, year, season, episode) {
249
  try {
250
  const isSeries = type === 'series';
 
268
  const rawTitle = item.title || '';
269
  const rawDesc = item.description || '';
270
  const combined = `${rawName} ${rawTitle} ${rawDesc}`.replace(/\n/g, ' ');
271
+ const upper = combined.toUpperCase();
272
 
273
  const infoHashMatch = (item.url || '').match(/btih:([a-fA-F0-9]{32,40})/i);
274
  const infoHash = item.infoHash || (infoHashMatch ? infoHashMatch[1] : null);
 
277
  const sizeGB = getSizeGB({ title: combined })
278
  ?? (typeof item.size === 'number' ? item.size / 1073741824 : null)
279
  ?? (typeof item.bytes === 'number' ? item.bytes / 1073741824 : null);
280
+ const sizeStr = sizeGB ? sizeGB.toFixed(2) + ' GB' : 'Unknown Size';
281
 
282
  const seeders = typeof item.seeders === 'number'
283
  ? item.seeders
284
  : (() => { const m = combined.match(/🌱\s*(\d+)/) || combined.match(/πŸ‘€\s*(\d+)/) || combined.match(/(\d+)\s*seed/i); return m ? parseInt(m[1], 10) : 0; })();
285
 
286
+ const provider = (item.provider || item.source || item.indexer || 'TorrentClaw').toString();
287
+ const quality = getQuality(combined) || getQuality(upper);
288
+
289
+ // Build a HEVC/HDR/DV/audio tag line, same spirit as the original TorrentClaw output
290
+ const tags = [];
291
+ if (upper.includes('DV') || upper.includes('DOLBY VISION')) tags.push('DV');
292
+ if (upper.includes('HDR10+')) tags.push('HDR10+');
293
+ else if (upper.includes('HDR10')) tags.push('HDR10');
294
+ else if (upper.includes('HDR')) tags.push('HDR');
295
+ if (upper.includes('HEVC') || upper.includes('X265') || upper.includes('H265')) tags.push('HEVC');
296
  const langLine = getLangLine(combined);
297
+ if (langLine) tags.push(langLine);
298
+ else tags.push('English');
299
+ const tagLine = tags.join(' β€’ ');
300
+
301
+ const unifiedTitle = buildUnifiedTitle({
302
+ isSeries: type === 'series', title, year, season, episode,
303
+ quality, tagLine, seeders, sizeStr, provider,
304
+ });
305
+ const unifiedName = buildUnifiedName({ quality, seeders, sourceLabel: '🦞 TorrentClaw' });
306
 
307
  streams.push({
308
  infoHash: infoHash ? infoHash.toLowerCase() : undefined,
309
  url: infoHash ? undefined : item.url,
310
+ name: unifiedName,
311
+ title: unifiedTitle,
312
  sources: [],
313
+ _quality: quality,
314
  _seeders: seeders,
315
  _sizeGB: sizeGB,
316
  _source: 'torrentclaw',
 
322
  } catch (e) { console.error('[TorrentClaw]', e.message); return []; }
323
  }
324
 
325
+ // ─── Torrentio scraper (config/logic from the original Torrentio script) ─
326
+ async function fetchTorrentio(type, id, title, year, season, episode) {
327
  try {
328
+ const url = `${TORRENTIO_BASE}/stream/${type}/${id}.json`;
329
+ const { data } = await fetchWithProxy(url);
330
+
331
+ const streams = (data.streams || []).map(s => {
332
+ const origTitle = s.title || '';
333
+ const providerMatch = origTitle.match(/βš™οΈ\s*(\S+)/);
334
+ let provider = providerMatch ? providerMatch[1] : 'Unknown';
335
+ provider = provider.toLowerCase().replace(/\.(to|com|org|net|io)$/, '');
336
+ if (provider === 'thepiratebay') provider = 'thepiratesbay';
337
+ if (provider === 'nyaa.si') provider = 'nyaa';
338
+ if (provider === 'limetorrents') provider = 'limetorrent';
339
+ if (provider === 'kat') provider = 'kickasstorrents';
340
+
341
+ const seeders = s.behaviorHints?.seeders
342
+ ?? (() => { const m = origTitle.match(/πŸ‘€\s*(\d+)/) || origTitle.match(/🌱\s*(\d+)/); return m ? parseInt(m[1], 10) : 0; })();
343
+ const sizeGB = getSizeGB(s);
344
+ const sizeStr = sizeGB ? sizeGB.toFixed(2) + ' GB' : 'Unknown Size';
345
+ const quality = getQuality(origTitle) || getQuality(s.name || '');
346
+
347
+ const upper = origTitle.toUpperCase();
348
+ const tags = [];
349
+ if (upper.includes('DV') || upper.includes('DOLBY VISION')) tags.push('DV');
350
+ if (upper.includes('HDR10+')) tags.push('HDR10+');
351
+ else if (upper.includes('HDR10')) tags.push('HDR10');
352
+ else if (upper.includes('HDR')) tags.push('HDR');
353
+ if (upper.includes('HEVC') || upper.includes('X265') || upper.includes('H265')) tags.push('HEVC');
354
+ const langLine = getLangLine(origTitle);
355
+ if (langLine) tags.push(langLine);
356
+ else tags.push('English');
357
+ const tagLine = tags.join(' β€’ ');
358
+
359
+ const unifiedTitle = buildUnifiedTitle({
360
+ isSeries: type === 'series', title, year, season, episode,
361
+ quality, tagLine, seeders, sizeStr, provider,
362
+ });
363
+ const unifiedName = buildUnifiedName({ quality, seeders, sourceLabel: 'βš™οΈ Torrentio' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
 
365
+ return {
366
+ ...s,
367
+ name: unifiedName,
368
+ title: unifiedTitle,
 
 
369
  _quality: quality,
370
  _seeders: seeders,
371
  _sizeGB: sizeGB,
372
+ _source: 'torrentio',
373
  _provider: provider,
374
+ };
375
+ });
376
+ console.log(`[Torrentio] ${streams.length} for ${type}/${id}`);
377
  return streams;
378
  } catch (e) { console.error('[Torrentio]', e.message); return []; }
379
  }
 
396
  // ─── Stream handler ────────────────────────────────────────────────
397
  app.get('/stream/:type/:id.json', async (req, res) => {
398
  const { type, id } = req.params;
399
+ const isSeries = type === 'series';
400
  const [imdbId, season = '', episode = ''] = id.split(':');
401
 
402
  const meta = await tmdbLookup(imdbId);
403
  const title = meta?.title || '';
404
  const year = meta?.year || '';
405
 
406
+ const [torrentclawR, torrentioR] = await Promise.allSettled([
 
407
  scrapeTorrentClaw(type, imdbId, title, year, season, episode),
408
+ fetchTorrentio(type, id, title, year, season, episode),
409
  ]);
410
 
411
+ const torrentclaw = torrentclawR.status === 'fulfilled' ? (torrentclawR.value || []) : [];
412
+ const torrentio = torrentioR.status === 'fulfilled' ? (torrentioR.value || []) : [];
413
 
414
+ const streams = processStreams([...torrentclaw, ...torrentio], type);
415
+
416
+ console.log(`[stream] ${type}/${id} β†’ torrentclaw:${torrentclaw.length} torrentio:${torrentio.length} final:${streams.length}`);
417
  res.json({ streams });
418
  });
419
 
420
  // ─── Diagnostic endpoints ─────────────────────────────────────────────
421
  app.get('/diag/:type/:id', async (req, res) => {
422
  const { type, id } = req.params;
423
+ const isSeries = type === 'series';
424
  const [imdbId, season = '', episode = ''] = id.split(':');
425
  const meta = await tmdbLookup(imdbId);
426
+ const [torrentclawR, torrentioR] = await Promise.allSettled([
 
427
  scrapeTorrentClaw(type, imdbId, meta?.title || '', meta?.year || '', season, episode),
428
+ fetchTorrentio(type, id, meta?.title || '', meta?.year || '', season, episode),
429
  ]);
 
430
  res.json({
431
+ imdbId, title: meta?.title, year: meta?.year, isSeries,
432
  tmdb: meta ? `OK: ${meta.title} (${meta.year})` : 'FAILED',
433
+ torrentclaw: { count: (torrentclawR.value || []).length, err: torrentclawR.reason?.message || null },
434
+ torrentio: { count: (torrentioR.value || []).length, err: torrentioR.reason?.message || null },
435
  });
436
  });
437
 
438
+ app.get('/diag-raw/torrentclaw/:type/:id', async (req, res) => {
439
  const { type, id } = req.params;
440
  try {
441
+ const isSeries = type === 'series';
442
  const [imdbId, season = '', episode = ''] = id.split(':');
443
+ const path = isSeries ? `series/${imdbId}:${season || 1}:${episode || 1}` : `movie/${imdbId}`;
444
+ const url = `${TORRENTCLAW_API}/stream/${path}.json`;
445
+ const { data } = await axios.get(url, { timeout: 15000, headers: { 'User-Agent': UA, 'Accept': 'application/json' } });
446
+ res.json((data.streams || []).slice(0, 3));
447
+ } catch (e) { res.json({ error: e.message }); }
448
+ });
449
 
450
+ app.get('/diag-raw/torrentio/:type/:id', async (req, res) => {
451
+ const { type, id } = req.params;
452
+ try {
453
+ const url = `${TORRENTIO_BASE}/stream/${type}/${id}.json`;
454
+ const { data } = await fetchWithProxy(url);
455
+ res.json((data.streams || []).slice(0, 3).map(s => ({
456
+ name: s.name, title: s.title, behaviorHints: s.behaviorHints,
457
+ })));
458
  } catch (e) { res.json({ error: e.message }); }
459
  });
460