vanseleem commited on
Commit
91642f7
ยท
verified ยท
1 Parent(s): f8187e9

Update index.js

Browse files
Files changed (1) hide show
  1. index.js +96 -110
index.js CHANGED
@@ -32,7 +32,7 @@ const QUALITY_RANK = {
32
  '4k': 5, '2160p': 5, 'webrip': 6, 'webdl': 6,
33
  };
34
 
35
- const PRIORITY_PROVIDERS = ['yts', 'knaben', 'torrentproject', 'eztv', 'nyaasi', 'thepiratebay'];
36
 
37
  const ALLOWED_PROVIDERS = [
38
  'yts',
@@ -47,9 +47,17 @@ const ALLOWED_PROVIDERS = [
47
  'kickasstorrents',
48
  'animetosho',
49
  'tokyotosho',
50
- 'torrentproject',
51
  ];
52
 
 
 
 
 
 
 
 
 
 
53
  const TR = [
54
  'udp://tracker.opentrackr.org:1337/announce',
55
  'udp://open.stealth.si:80/announce',
@@ -63,7 +71,6 @@ const TR = [
63
  'https://tracker.zhuqiy.com:443/announce',
64
  ].map(t => '&tr=' + encodeURIComponent(t)).join('');
65
 
66
- // โ”€โ”€โ”€ Proxy fallback function โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
67
  async function fetchWithProxy(url, options = {}) {
68
  const proxies = [
69
  url,
@@ -71,14 +78,12 @@ async function fetchWithProxy(url, options = {}) {
71
  PROXY_2 + encodeURIComponent(url),
72
  PROXY_3 + encodeURIComponent(url),
73
  ];
74
-
75
  for (const proxyUrl of proxies) {
76
  try {
77
  const response = await axios.get(proxyUrl, { timeout: 12000, headers: { 'User-Agent': UA }, ...options });
78
  return response;
79
  } catch (e) {
80
- console.warn(`[Proxy] Failed: ${proxyUrl.substring(0, 50)}... Error: ${e.message}`);
81
- continue;
82
  }
83
  }
84
  throw new Error('All proxy attempts failed');
@@ -162,6 +167,16 @@ function getLangLine(str = '') {
162
  return [...new Set(found)].join(' / ');
163
  }
164
 
 
 
 
 
 
 
 
 
 
 
165
  function processStreams(streams, type) {
166
  const maxSize = type === 'series' ? MAX_SIZE_GB_SERIES : MAX_SIZE_GB_MOVIE;
167
 
@@ -172,6 +187,7 @@ function processStreams(streams, type) {
172
  if (!q || !ALLOWED_QUALITIES.includes(q)) return false;
173
  const sizeGB = s._sizeGB ?? getSizeGB(s);
174
  if (sizeGB !== null && sizeGB > maxSize) return false;
 
175
  return true;
176
  });
177
 
@@ -219,8 +235,8 @@ async function tmdbLookup(imdbId) {
219
  );
220
  const movie = data.movie_results?.[0];
221
  const tv = data.tv_results?.[0];
222
- if (movie) return { title: movie.title || movie.original_title, year: (movie.release_date || '').slice(0, 4) };
223
- if (tv) return { title: tv.name || tv.original_name, year: (tv.first_air_date || '').slice(0, 4) };
224
  return null;
225
  } catch (e) { console.error('[TMDB]', e.message); return null; }
226
  }
@@ -242,14 +258,14 @@ async function scrapeYTS(imdbId) {
242
  const sizeStr = sizeGB ? sizeGB.toFixed(2) + ' GB' : (t.size || '');
243
  const langLine = getLangLine(qualityStr);
244
  streams.push({
245
- infoHash: t.hash.toLowerCase(),
246
- name: qualityStr,
247
- title: `โ˜€๏ธ ${movie.title} (${movie.year})\n๐ŸŒฑ ${t.seeds || 0}\n๐Ÿ’พ ${sizeStr}\n๐Ÿ… YTS${langLine ? '\n๐Ÿ”Š ' + langLine : ''}`,
248
- sources: [],
249
- _quality: getQuality(qualityStr),
250
- _seeders: t.seeds || 0,
251
- _sizeGB: sizeGB,
252
- _source: 'yts',
253
  _provider: 'yts',
254
  });
255
  }
@@ -259,23 +275,45 @@ async function scrapeYTS(imdbId) {
259
  } catch (e) { console.error('[YTS]', e.message); return []; }
260
  }
261
 
262
- async function scrapeKnaben(title, year, isSeries, season, episode) {
263
  if (!title) return [];
264
  try {
265
  let query = title;
266
- if (year && !isSeries) query += ' ' + year;
267
  if (isSeries && season && episode)
268
  query += ` S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`;
269
- const categories = isSeries ? [5000000, 5001000] : [2000000, 2001000];
 
 
 
 
 
 
 
 
270
  const { data } = await axios.post(
271
  'https://api.knaben.org/v1',
272
- { search_type: '75%', search_field: 'title', query, order_by: 'seeders', order_direction: 'desc', categories, from: 0, size: 40, hide_unsafe: true, hide_xxx: true },
 
 
 
 
 
 
 
 
 
 
 
273
  { timeout: 14000, headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA } }
274
  );
 
275
  if (!data?.hits?.length) return [];
 
276
  const streams = [];
277
  for (const hit of data.hits) {
278
  if (!hit.magnetUrl && !hit.hash) continue;
 
 
279
  const magnet = hit.magnetUrl || buildMagnet(hit.hash, hit.title || '');
280
  const hashMatch = magnet.match(/btih:([a-fA-F0-9]{32,40})/i);
281
  const hash = hashMatch ? hashMatch[1] : (hit.hash || '');
@@ -284,62 +322,22 @@ async function scrapeKnaben(title, year, isSeries, season, episode) {
284
  const sizeStr = sizeGB ? sizeGB.toFixed(2) + ' GB' : '';
285
  const langLine = getLangLine(hit.title || '');
286
  streams.push({
287
- infoHash: hash.toLowerCase(),
288
- name: hit.title || '',
289
- title: `โ˜€๏ธ ${title}${year ? ' (' + year + ')' : ''}\n๐ŸŒฑ ${hit.seeders || 0}\n๐Ÿ’พ ${sizeStr}\n๐Ÿ… Knaben${langLine ? '\n๐Ÿ”Š ' + langLine : ''}`,
290
- sources: [],
291
- _quality: getQuality(hit.title || ''),
292
- _seeders: hit.seeders || 0,
293
- _sizeGB: sizeGB,
294
- _source: 'knaben',
295
  _provider: 'knaben',
296
  });
297
  }
298
- console.log(`[Knaben] ${streams.length} for "${query}"`);
299
  return streams;
300
  } catch (e) { console.error('[Knaben]', e.message); return []; }
301
  }
302
 
303
- async function scrapeTorrentProject(title, year, isSeries, season, episode) {
304
- if (!title) return [];
305
- try {
306
- let query = title;
307
- if (year && !isSeries) query += ' ' + year;
308
- if (isSeries && season && episode)
309
- query += ` S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`;
310
- // filter: 2000 = video/movies, 2101 = TV series
311
- const filter = isSeries ? 2101 : 2000;
312
- const url = `https://torrentproject.com.se/?s=${encodeURIComponent(query)}&out=json&filter=${filter}&num=40&orderby=best`;
313
- const { data } = await axios.get(url, { timeout: 14000, headers: { 'User-Agent': UA } });
314
- if (!data || typeof data !== 'object') return [];
315
- const streams = [];
316
- for (const key of Object.keys(data)) {
317
- if (key === 'total_found') continue;
318
- const item = data[key];
319
- if (!item.torrent_hash) continue;
320
- const hash = item.torrent_hash.toLowerCase();
321
- const sizeGB = item.torrent_size ? item.torrent_size / 1073741824 : null;
322
- const sizeStr = sizeGB ? sizeGB.toFixed(2) + ' GB' : '';
323
- const quality = getQuality(item.title || '');
324
- if (!quality) continue;
325
- const langLine = getLangLine(item.title || '');
326
- streams.push({
327
- infoHash: hash,
328
- name: item.title || '',
329
- title: `โ˜€๏ธ ${title}${year ? ' (' + year + ')' : ''}\n๐ŸŒฑ ${item.seeds || 0}\n๐Ÿ’พ ${sizeStr}\n๐Ÿ… TorrentProject${langLine ? '\n๐Ÿ”Š ' + langLine : ''}`,
330
- sources: [buildMagnet(hash, item.title || '')],
331
- _quality: quality,
332
- _seeders: item.seeds || 0,
333
- _sizeGB: sizeGB,
334
- _source: 'torrentproject',
335
- _provider: 'torrentproject',
336
- });
337
- }
338
- console.log(`[TorrentProject] ${streams.length} for "${query}"`);
339
- return streams;
340
- } catch (e) { console.error('[TorrentProject]', e.message); return []; }
341
- }
342
-
343
  async function fetchTorrentio(type, id, title, year) {
344
  try {
345
  const url = `${TORRENTIO_BASE}/stream/${type}/${id}.json`;
@@ -359,11 +357,11 @@ async function fetchTorrentio(type, id, title, year) {
359
  const langLine = getLangLine(origTitle);
360
  return {
361
  ...s,
362
- title: `โ˜€๏ธ ${title}${year ? ' (' + year + ')' : ''}\n๐ŸŒฑ ${seeders}\n๐Ÿ’พ ${sizeGB ? sizeGB.toFixed(2) + ' GB' : 'N/A'}\n๐Ÿ… ${provider}${langLine ? '\n๐Ÿ”Š ' + langLine : ''}`,
363
- _quality: getQuality(s.title || s.name || ''),
364
- _seeders: seeders,
365
- _sizeGB: sizeGB,
366
- _source: 'torrentio',
367
  _provider: provider,
368
  };
369
  });
@@ -393,25 +391,24 @@ app.get('/stream/:type/:id.json', async (req, res) => {
393
  const isSeries = type === 'series';
394
  const [imdbId, season = '', episode = ''] = id.split(':');
395
 
396
- const meta = await tmdbLookup(imdbId);
397
- const title = meta?.title || '';
398
- const year = meta?.year || '';
 
399
 
400
- const [ytsR, knabenR, torrentprojectR, torrentioR] = await Promise.allSettled([
401
  isSeries ? Promise.resolve([]) : scrapeYTS(imdbId),
402
- scrapeKnaben(title, year, isSeries, season, episode),
403
- scrapeTorrentProject(title, year, isSeries, season, episode),
404
  fetchTorrentio(type, id, title, year),
405
  ]);
406
 
407
- const yts = ytsR.status === 'fulfilled' ? (ytsR.value || []) : [];
408
- const knaben = knabenR.status === 'fulfilled' ? (knabenR.value || []) : [];
409
- const torrentproject = torrentprojectR.status === 'fulfilled' ? (torrentprojectR.value || []) : [];
410
- const torrentio = torrentioR.status === 'fulfilled' ? (torrentioR.value || []) : [];
411
 
412
- const streams = processStreams([...yts, ...knaben, ...torrentproject, ...torrentio], type);
413
 
414
- console.log(`[stream] ${type}/${id} โ†’ yts:${yts.length} knaben:${knaben.length} torrentproject:${torrentproject.length} torrentio:${torrentio.length} final:${streams.length}`);
415
  res.json({ streams });
416
  });
417
 
@@ -420,20 +417,19 @@ app.get('/diag/:type/:id', async (req, res) => {
420
  const { type, id } = req.params;
421
  const isSeries = type === 'series';
422
  const [imdbId, season = '', episode = ''] = id.split(':');
423
- const meta = await tmdbLookup(imdbId);
424
- const [ytsR, knabenR, torrentprojectR, torrentioR] = await Promise.allSettled([
 
425
  isSeries ? Promise.resolve([]) : scrapeYTS(imdbId),
426
- scrapeKnaben(meta?.title || '', meta?.year || '', isSeries, season, episode),
427
- scrapeTorrentProject(meta?.title || '', meta?.year || '', isSeries, season, episode),
428
  fetchTorrentio(type, id, meta?.title || '', meta?.year || ''),
429
  ]);
430
  res.json({
431
- imdbId, title: meta?.title, year: meta?.year, isSeries,
432
- tmdb: meta ? `OK: ${meta.title} (${meta.year})` : 'FAILED',
433
- yts: { count: (ytsR.value || []).length, err: ytsR.reason?.message || null },
434
- knaben: { count: (knabenR.value || []).length, err: knabenR.reason?.message || null },
435
- torrentproject: { count: (torrentprojectR.value || []).length, err: torrentprojectR.reason?.message || null },
436
- torrentio: { count: (torrentioR.value || []).length, err: torrentioR.reason?.message || null },
437
  });
438
  });
439
 
@@ -492,7 +488,7 @@ app.get('/', (req, res) => {
492
  background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
493
  border-radius: 16px; padding: 20px 22px;
494
  }
495
- .card-label { font-size: 0.68rem; font-weight: 700; text-trans
496
  .url-box {
497
  font-family: 'SF Mono','Fira Code','Consolas',monospace; font-size: 0.8rem;
498
  color: #ffffff; background: rgba(0,0,0,0.25); border: 1px solid rgba(255,0,0,0.12);
@@ -500,20 +496,10 @@ app.get('/', (req, res) => {
500
  }
501
  .actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
502
  .btn { padding: 10px 22px; border-radius: 10px; font-size: 0.84rem; font-weight: 600; text-decoration: none; border: none; cursor: pointer; transition: all 0.15s; font-family: inherit; }
503
- .btn-install {
504
- background: #FF0000;
505
- color: #ffffff;
506
- }
507
  .btn-install:hover { background: #cc0000; }
508
- .btn-copy {
509
- background: #ffffff;
510
- color: #050810;
511
- border: 1px solid #ffffff;
512
- }
513
- .btn-copy:hover {
514
- background: #e6e6e6;
515
- border-color: #e6e6e6;
516
- }
517
  .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
518
  .stat { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px; padding: 16px 12px; text-align: center; }
519
  .stat-val { font-size: 1.5rem; font-weight: 800; color: #fff; line-height: 1; margin-bottom: 5px; }
 
32
  '4k': 5, '2160p': 5, 'webrip': 6, 'webdl': 6,
33
  };
34
 
35
+ const PRIORITY_PROVIDERS = ['yts', 'knaben', 'torrentsdb', 'eztv', 'nyaasi', 'thepiratebay'];
36
 
37
  const ALLOWED_PROVIDERS = [
38
  'yts',
 
47
  'kickasstorrents',
48
  'animetosho',
49
  'tokyotosho',
 
50
  ];
51
 
52
+ // Knaben confirmed category IDs (from live browse URLs)
53
+ // Movies: 3000000 (all), 3001000 (HD), 3002000 (SD), 3003000 (UHD)
54
+ // TV: 2000000 (all), 2001000 (HD), 2002000 (SD), 2003000 (UHD), 2007000 (Cartoon)
55
+ // Anime: 6000000 (all), 6001000 (Subbed), 6002000 (Dubbed), 6003000 (Dual), 6004000 (Raw)
56
+ // XXX blocked via hide_xxx: true
57
+ const KNABEN_CATS_MOVIE = [3000000, 3001000, 3002000, 3003000];
58
+ const KNABEN_CATS_SERIES = [2000000, 2001000, 2002000, 2003000, 2007000];
59
+ const KNABEN_CATS_ANIME = [6000000, 6001000, 6002000, 6003000];
60
+
61
  const TR = [
62
  'udp://tracker.opentrackr.org:1337/announce',
63
  'udp://open.stealth.si:80/announce',
 
71
  'https://tracker.zhuqiy.com:443/announce',
72
  ].map(t => '&tr=' + encodeURIComponent(t)).join('');
73
 
 
74
  async function fetchWithProxy(url, options = {}) {
75
  const proxies = [
76
  url,
 
78
  PROXY_2 + encodeURIComponent(url),
79
  PROXY_3 + encodeURIComponent(url),
80
  ];
 
81
  for (const proxyUrl of proxies) {
82
  try {
83
  const response = await axios.get(proxyUrl, { timeout: 12000, headers: { 'User-Agent': UA }, ...options });
84
  return response;
85
  } catch (e) {
86
+ console.warn(`[Proxy] Failed: ${proxyUrl.substring(0, 50)}... ${e.message}`);
 
87
  }
88
  }
89
  throw new Error('All proxy attempts failed');
 
167
  return [...new Set(found)].join(' / ');
168
  }
169
 
170
+ function isSexualTitle(title = '') {
171
+ const s = title.toLowerCase();
172
+ const banned = [
173
+ 'xxx', 'porn', 'sex', 'hentai', 'erotic', 'nude', 'naked', 'adult',
174
+ 'milf', 'anal', 'blowjob', 'cumshot', 'creampie', 'gangbang', 'lesbian sex',
175
+ 'brazzers', 'bangbros', 'mofos', 'naughty', 'onlyfans', 'playboy',
176
+ ];
177
+ return banned.some(b => s.includes(b));
178
+ }
179
+
180
  function processStreams(streams, type) {
181
  const maxSize = type === 'series' ? MAX_SIZE_GB_SERIES : MAX_SIZE_GB_MOVIE;
182
 
 
187
  if (!q || !ALLOWED_QUALITIES.includes(q)) return false;
188
  const sizeGB = s._sizeGB ?? getSizeGB(s);
189
  if (sizeGB !== null && sizeGB > maxSize) return false;
190
+ if (isSexualTitle(s.name || s.title || '')) return false;
191
  return true;
192
  });
193
 
 
235
  );
236
  const movie = data.movie_results?.[0];
237
  const tv = data.tv_results?.[0];
238
+ if (movie) return { title: movie.title || movie.original_title, year: (movie.release_date || '').slice(0, 4), isAnime: false };
239
+ if (tv) return { title: tv.name || tv.original_name, year: (tv.first_air_date || '').slice(0, 4), isAnime: tv.genre_ids?.includes(16) || false };
240
  return null;
241
  } catch (e) { console.error('[TMDB]', e.message); return null; }
242
  }
 
258
  const sizeStr = sizeGB ? sizeGB.toFixed(2) + ' GB' : (t.size || '');
259
  const langLine = getLangLine(qualityStr);
260
  streams.push({
261
+ infoHash: t.hash.toLowerCase(),
262
+ name: qualityStr,
263
+ title: `โ˜€๏ธ ${movie.title} (${movie.year})\n๐ŸŒฑ ${t.seeds || 0}\n๐Ÿ’พ ${sizeStr}\n๐Ÿ… YTS${langLine ? '\n๐Ÿ”Š ' + langLine : ''}`,
264
+ sources: [],
265
+ _quality: getQuality(qualityStr),
266
+ _seeders: t.seeds || 0,
267
+ _sizeGB: sizeGB,
268
+ _source: 'yts',
269
  _provider: 'yts',
270
  });
271
  }
 
275
  } catch (e) { console.error('[YTS]', e.message); return []; }
276
  }
277
 
278
+ async function scrapeKnaben(title, year, isSeries, isAnime, season, episode) {
279
  if (!title) return [];
280
  try {
281
  let query = title;
 
282
  if (isSeries && season && episode)
283
  query += ` S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`;
284
+ else if (!isSeries && year)
285
+ query += ' ' + year;
286
+
287
+ // Pick the right categories โ€” no overlap between movies/TV/anime
288
+ let categories;
289
+ if (isAnime) categories = KNABEN_CATS_ANIME;
290
+ else if (isSeries) categories = KNABEN_CATS_SERIES;
291
+ else categories = KNABEN_CATS_MOVIE;
292
+
293
  const { data } = await axios.post(
294
  'https://api.knaben.org/v1',
295
+ {
296
+ search_type: '75%',
297
+ search_field: 'title',
298
+ query,
299
+ order_by: 'seeders',
300
+ order_direction: 'desc',
301
+ categories,
302
+ from: 0,
303
+ size: 50,
304
+ hide_unsafe: true,
305
+ hide_xxx: true, // blocks XXX category at API level
306
+ },
307
  { timeout: 14000, headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA } }
308
  );
309
+
310
  if (!data?.hits?.length) return [];
311
+
312
  const streams = [];
313
  for (const hit of data.hits) {
314
  if (!hit.magnetUrl && !hit.hash) continue;
315
+ // Post-filter: reject if title contains sexual keywords
316
+ if (isSexualTitle(hit.title || '')) continue;
317
  const magnet = hit.magnetUrl || buildMagnet(hit.hash, hit.title || '');
318
  const hashMatch = magnet.match(/btih:([a-fA-F0-9]{32,40})/i);
319
  const hash = hashMatch ? hashMatch[1] : (hit.hash || '');
 
322
  const sizeStr = sizeGB ? sizeGB.toFixed(2) + ' GB' : '';
323
  const langLine = getLangLine(hit.title || '');
324
  streams.push({
325
+ infoHash: hash.toLowerCase(),
326
+ name: hit.title || '',
327
+ title: `โ˜€๏ธ ${title}${year ? ' (' + year + ')' : ''}\n๐ŸŒฑ ${hit.seeders || 0}\n๐Ÿ’พ ${sizeStr}\n๐Ÿ… Knaben${langLine ? '\n๐Ÿ”Š ' + langLine : ''}`,
328
+ sources: [],
329
+ _quality: getQuality(hit.title || ''),
330
+ _seeders: hit.seeders || 0,
331
+ _sizeGB: sizeGB,
332
+ _source: 'knaben',
333
  _provider: 'knaben',
334
  });
335
  }
336
+ console.log(`[Knaben] ${streams.length} for "${query}" cats:[${categories}]`);
337
  return streams;
338
  } catch (e) { console.error('[Knaben]', e.message); return []; }
339
  }
340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  async function fetchTorrentio(type, id, title, year) {
342
  try {
343
  const url = `${TORRENTIO_BASE}/stream/${type}/${id}.json`;
 
357
  const langLine = getLangLine(origTitle);
358
  return {
359
  ...s,
360
+ title: `โ˜€๏ธ ${title}${year ? ' (' + year + ')' : ''}\n๐ŸŒฑ ${seeders}\n๐Ÿ’พ ${sizeGB ? sizeGB.toFixed(2) + ' GB' : 'N/A'}\n๐Ÿ… ${provider}${langLine ? '\n๐Ÿ”Š ' + langLine : ''}`,
361
+ _quality: getQuality(s.title || s.name || ''),
362
+ _seeders: seeders,
363
+ _sizeGB: sizeGB,
364
+ _source: 'torrentio',
365
  _provider: provider,
366
  };
367
  });
 
391
  const isSeries = type === 'series';
392
  const [imdbId, season = '', episode = ''] = id.split(':');
393
 
394
+ const meta = await tmdbLookup(imdbId);
395
+ const title = meta?.title || '';
396
+ const year = meta?.year || '';
397
+ const isAnime = meta?.isAnime || false;
398
 
399
+ const [ytsR, knabenR, torrentioR] = await Promise.allSettled([
400
  isSeries ? Promise.resolve([]) : scrapeYTS(imdbId),
401
+ scrapeKnaben(title, year, isSeries, isAnime, season, episode),
 
402
  fetchTorrentio(type, id, title, year),
403
  ]);
404
 
405
+ const yts = ytsR.status === 'fulfilled' ? (ytsR.value || []) : [];
406
+ const knaben = knabenR.status === 'fulfilled' ? (knabenR.value || []) : [];
407
+ const torrentio = torrentioR.status === 'fulfilled' ? (torrentioR.value || []) : [];
 
408
 
409
+ const streams = processStreams([...yts, ...knaben, ...torrentio], type);
410
 
411
+ console.log(`[stream] ${type}/${id} โ†’ yts:${yts.length} knaben:${knaben.length} torrentio:${torrentio.length} final:${streams.length} anime:${isAnime}`);
412
  res.json({ streams });
413
  });
414
 
 
417
  const { type, id } = req.params;
418
  const isSeries = type === 'series';
419
  const [imdbId, season = '', episode = ''] = id.split(':');
420
+ const meta = await tmdbLookup(imdbId);
421
+ const isAnime = meta?.isAnime || false;
422
+ const [ytsR, knabenR, torrentioR] = await Promise.allSettled([
423
  isSeries ? Promise.resolve([]) : scrapeYTS(imdbId),
424
+ scrapeKnaben(meta?.title || '', meta?.year || '', isSeries, isAnime, season, episode),
 
425
  fetchTorrentio(type, id, meta?.title || '', meta?.year || ''),
426
  ]);
427
  res.json({
428
+ imdbId, title: meta?.title, year: meta?.year, isSeries, isAnime,
429
+ tmdb: meta ? `OK: ${meta.title} (${meta.year})` : 'FAILED',
430
+ yts: { count: (ytsR.value || []).length, err: ytsR.reason?.message || null },
431
+ knaben: { count: (knabenR.value || []).length, err: knabenR.reason?.message || null },
432
+ torrentio: { count: (torrentioR.value || []).length, err: torrentioR.reason?.message || null },
 
433
  });
434
  });
435
 
 
488
  background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
489
  border-radius: 16px; padding: 20px 22px;
490
  }
491
+ .card-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #ffffff; margin-bottom: 10px; }
492
  .url-box {
493
  font-family: 'SF Mono','Fira Code','Consolas',monospace; font-size: 0.8rem;
494
  color: #ffffff; background: rgba(0,0,0,0.25); border: 1px solid rgba(255,0,0,0.12);
 
496
  }
497
  .actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
498
  .btn { padding: 10px 22px; border-radius: 10px; font-size: 0.84rem; font-weight: 600; text-decoration: none; border: none; cursor: pointer; transition: all 0.15s; font-family: inherit; }
499
+ .btn-install { background: #FF0000; color: #ffffff; }
 
 
 
500
  .btn-install:hover { background: #cc0000; }
501
+ .btn-copy { background: #ffffff; color: #050810; border: 1px solid #ffffff; }
502
+ .btn-copy:hover { background: #e6e6e6; border-color: #e6e6e6; }
 
 
 
 
 
 
 
503
  .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
504
  .stat { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px; padding: 16px 12px; text-align: center; }
505
  .stat-val { font-size: 1.5rem; font-weight: 800; color: #fff; line-height: 1; margin-bottom: 5px; }