vanseleem commited on
Commit
f4ec155
Β·
verified Β·
1 Parent(s): ac47323

Update index.js

Browse files
Files changed (1) hide show
  1. index.js +347 -67
index.js CHANGED
@@ -1,6 +1,8 @@
1
  'use strict';
 
2
  const express = require('express');
3
  const axios = require('axios');
 
4
  const app = express();
5
  const PORT = process.env.PORT || 7860;
6
 
@@ -14,21 +16,37 @@ app.use((req, res, next) => {
14
 
15
  const TMDB_KEY = '83d364331c40bfbe29858aeed82f45cc';
16
  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';
17
- const TORRENTIO_BASE = 'https://aniscraper.nmtl.app/source=animetosho&p2p=true&v=2.5.1/configure';
18
- const TORRENTIO_STREM = 'https://torrentio.strem.fun';
 
 
 
19
 
20
  const MAX_SIZE_GB_MOVIE = 4;
21
  const MAX_SIZE_GB_SERIES = 1.5;
22
  const LINKS_PER_QUALITY = 5;
23
  const ALLOWED_QUALITIES = ['4k', '1080p', '720p', '576p', '480p', 'webrip'];
 
24
  const QUALITY_RANK = {
25
  '1080p': 1, '720p': 2, '576p': 3, '480p': 4,
26
  '4k': 5, '2160p': 5, 'webrip': 6, 'webdl': 6,
27
  };
 
28
  const PRIORITY_PROVIDERS = ['yts', 'knaben', 'torrentsdb', 'eztv', 'nyaasi', 'thepiratebay'];
 
29
  const ALLOWED_PROVIDERS = [
30
- 'yts', 'knaben', 'thepiratesbay', 'thepiratebay', 'eztv', 'torrentcsv',
31
- 'nyaa', 'nyaasi', 'limetorrent', 'kickasstorrents', 'animetosho', 'tokyotosho',
 
 
 
 
 
 
 
 
 
 
32
  ];
33
 
34
  const TR = [
@@ -44,9 +62,31 @@ const TR = [
44
  'https://tracker.zhuqiy.com:443/announce',
45
  ].map(t => '&tr=' + encodeURIComponent(t)).join('');
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  function buildMagnet(hash, name) {
48
  return 'magnet:?xt=urn:btih:' + hash.toLowerCase() + '&dn=' + encodeURIComponent(name) + TR;
49
  }
 
50
  function getQuality(str = '') {
51
  const s = str.toLowerCase();
52
  if (s.includes('4k') || s.includes('2160p') || s.includes('uhd')) return '4k';
@@ -58,6 +98,7 @@ function getQuality(str = '') {
58
  if (s.includes('webdl') || s.includes('web-dl')) return 'webdl';
59
  return null;
60
  }
 
61
  function getSizeGB(stream) {
62
  const raw = stream.title || stream.name || '';
63
  const m = raw.match(/πŸ’Ύ\s*([\d.]+)\s*(GB|MB)/i) || raw.match(/([\d.]+)\s*(GB|MB)/i);
@@ -65,6 +106,7 @@ function getSizeGB(stream) {
65
  const val = parseFloat(m[1]);
66
  return m[2].toUpperCase() === 'GB' ? val : val / 1024;
67
  }
 
68
  function getSeeders(stream) {
69
  if (stream._seeders != null) return stream._seeders;
70
  if (stream.behaviorHints?.seeders) return stream.behaviorHints.seeders;
@@ -72,8 +114,56 @@ function getSeeders(stream) {
72
  return m ? parseInt(m[1], 10) : 0;
73
  }
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  function processStreams(streams, type) {
76
  const maxSize = type === 'series' ? MAX_SIZE_GB_SERIES : MAX_SIZE_GB_MOVIE;
 
77
  const filtered = streams.filter(s => {
78
  if (!s.infoHash && !s.url) return false;
79
  if (s._provider && !ALLOWED_PROVIDERS.includes(s._provider.toLowerCase())) return false;
@@ -83,19 +173,23 @@ function processStreams(streams, type) {
83
  if (sizeGB !== null && sizeGB > maxSize) return false;
84
  return true;
85
  });
 
86
  const seen = new Set();
87
  const unique = filtered.filter(s => {
88
  const key = (s.infoHash || '').toLowerCase();
89
  if (!key) return true;
90
  if (seen.has(key)) return false;
91
- seen.add(key); return true;
 
92
  });
 
93
  const byQuality = {};
94
  unique.forEach(s => {
95
  const q = s._quality || getQuality(s.title || s.name || '') || 'unknown';
96
  if (!byQuality[q]) byQuality[q] = [];
97
  byQuality[q].push(s);
98
  });
 
99
  for (const q in byQuality) {
100
  byQuality[q].sort((a, b) => {
101
  const rankA = PRIORITY_PROVIDERS.indexOf((a._source || '').toLowerCase());
@@ -106,7 +200,11 @@ function processStreams(streams, type) {
106
  return getSeeders(b) - getSeeders(a);
107
  });
108
  }
109
- const sortedQualities = Object.keys(byQuality).sort((a, b) => (QUALITY_RANK[a] || 99) - (QUALITY_RANK[b] || 99));
 
 
 
 
110
  const result = [];
111
  sortedQualities.forEach(q => result.push(...byQuality[q].slice(0, LINKS_PER_QUALITY)));
112
  return result;
@@ -126,105 +224,287 @@ async function tmdbLookup(imdbId) {
126
  } catch (e) { console.error('[TMDB]', e.message); return null; }
127
  }
128
 
129
- // ─── shared torrentio stream normaliser ───────────────────────────────
130
- function normaliseTorrentio(streams, title, year) {
131
- return streams.map(s => {
132
- const providerMatch = (s.title || '').match(/βš™οΈ\s*(\S+)/);
133
- let provider = providerMatch ? providerMatch[1] : 'Unknown';
134
- provider = provider.toLowerCase().replace(/\.(to|com|org|net|io)$/, '');
135
- if (provider === 'thepiratebay') provider = 'thepiratesbay';
136
- if (provider === 'nyaa.si') provider = 'nyaa';
137
- if (provider === 'limetorrents') provider = 'limetorrent';
138
- if (provider === 'kat') provider = 'kickasstorrents';
139
- const seeders = s.behaviorHints?.seeders
140
- ?? (() => { const m = (s.title || '').match(/πŸ‘€\s*(\d+)/); return m ? parseInt(m[1], 10) : 0; })();
141
- const sizeGB = getSizeGB(s);
142
- return {
143
- ...s,
144
- title: `β˜€οΈ ${title}${year ? ' (' + year + ')' : ''}\n🌱 ${seeders}\nπŸ’Ύ ${sizeGB ? sizeGB.toFixed(2) + ' GB' : 'N/A'}\nπŸ… ${provider}`,
145
- _quality: getQuality(s.title || s.name || ''),
146
- _seeders: seeders,
147
- _sizeGB: sizeGB,
148
- _source: 'torrentio',
149
- _provider: provider,
150
- };
151
- });
 
 
 
 
 
 
 
 
 
152
  }
153
 
154
- // ─── Source 1: torrentsdb.com (original base) ─────────────────────────
155
- async function fetchTorrentio(type, id, title, year) {
156
  try {
157
- const { data } = await axios.get(`${TORRENTIO_BASE}/stream/${type}/${id}.json`, { timeout: 12000, headers: { 'User-Agent': UA } });
158
- const streams = normaliseTorrentio(data.streams || [], title, year);
159
- console.log(`[Torrentio/torrentsdb] ${streams.length} for ${type}/${id}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  return streams;
161
- } catch (e) { console.error('[Torrentio/torrentsdb]', e.message); return []; }
162
  }
163
 
164
- // ─── Source 2: torrentio.strem.fun (new) ──────────────────────────────
165
- async function fetchTorrentioStrem(type, id, title, year) {
166
  try {
167
- const { data } = await axios.get(`${TORRENTIO_STREM}/stream/${type}/${id}.json`, { timeout: 12000, headers: { 'User-Agent': UA } });
168
- const streams = normaliseTorrentio(data.streams || [], title, year);
169
- console.log(`[Torrentio/strem.fun] ${streams.length} for ${type}/${id}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  return streams;
171
- } catch (e) { console.error('[Torrentio/strem.fun]', e.message); return []; }
172
  }
173
 
174
- app.get('/manifest.json', (_, res) => res.json({
175
- id: 'org.ghostream.plus.v2', name: 'VanStreams+',
176
- description: 'Stream Everything You Want\nMade With 🀍 By Van',
177
- version: '15.0.7', resources: ['stream'], types: ['movie', 'series'],
178
- idPrefixes: ['tt'], catalogs: [],
179
- }));
 
 
 
 
 
 
 
 
180
 
 
181
  app.get('/stream/:type/:id.json', async (req, res) => {
182
  const { type, id } = req.params;
183
- const [imdbId] = id.split(':');
 
 
184
  const meta = await tmdbLookup(imdbId);
185
  const title = meta?.title || '';
186
  const year = meta?.year || '';
187
 
188
- const [t1R, t2R] = await Promise.allSettled([
 
 
189
  fetchTorrentio(type, id, title, year),
190
- fetchTorrentioStrem(type, id, title, year),
191
  ]);
192
- const t1 = t1R.status === 'fulfilled' ? (t1R.value || []) : [];
193
- const t2 = t2R.status === 'fulfilled' ? (t2R.value || []) : [];
194
 
195
- const streams = processStreams([...t1, ...t2], type);
196
- console.log(`[stream] ${type}/${id} β†’ torrentsdb:${t1.length} strem.fun:${t2.length} final:${streams.length}`);
 
 
 
 
 
197
  res.json({ streams });
198
  });
199
 
 
200
  app.get('/diag/:type/:id', async (req, res) => {
201
  const { type, id } = req.params;
202
- const [imdbId] = id.split(':');
 
203
  const meta = await tmdbLookup(imdbId);
204
- const [t1R, t2R] = await Promise.allSettled([
 
 
205
  fetchTorrentio(type, id, meta?.title || '', meta?.year || ''),
206
- fetchTorrentioStrem(type, id, meta?.title || '', meta?.year || ''),
207
  ]);
208
  res.json({
209
- imdbId, title: meta?.title, year: meta?.year,
210
- tmdb: meta ? `OK: ${meta.title} (${meta.year})` : 'FAILED',
211
- torrentsdb: { count: (t1R.value || []).length, err: t1R.reason?.message || null },
212
- strem_fun: { count: (t2R.value || []).length, err: t2R.reason?.message || null },
 
213
  });
214
  });
215
 
216
  app.get('/diag-raw/:type/:id', async (req, res) => {
217
  const { type, id } = req.params;
218
  try {
219
- const { data } = await axios.get(`${TORRENTIO_BASE}/stream/${type}/${id}.json`, { timeout: 12000, headers: { 'User-Agent': UA } });
220
- res.json((data.streams || []).slice(0, 3).map(s => ({ name: s.name, title: s.title, behaviorHints: s.behaviorHints })));
 
 
 
221
  } catch (e) { res.json({ error: e.message }); }
222
  });
223
 
 
224
  app.get('/', (req, res) => {
225
- const manifestUrl = 'https://vanstreams.duckdns.org/manifest.json';
226
- const installUrl = 'stremio://vanstreams.duckdns.org/manifest.json';
227
- res.type('html').send(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>VanStreams+</title><style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}body{font-family:-apple-system,'Segoe UI',system-ui,sans-serif;background:#050810;color:#dde6f0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:28px 20px;background-image:radial-gradient(ellipse 70% 40% at 50% 0%,rgba(255,0,0,.05) 0%,transparent 70%),radial-gradient(ellipse 40% 30% at 80% 80%,rgba(255,0,0,.03) 0%,transparent 60%)}.wrap{width:100%;max-width:560px;display:flex;flex-direction:column;gap:20px}.logo{font-size:2.6rem;font-weight:800;letter-spacing:-1px;line-height:1}.logo .van{color:#F00}.logo .streams{color:#fff}.badge-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.badge{display:inline-flex;align-items:center;gap:5px;background:rgba(255,0,0,.08);border:1px solid rgba(255,0,0,.18);color:#fff;padding:4px 11px;border-radius:100px;font-size:.72rem;font-weight:700;letter-spacing:.4px;text-transform:uppercase}.desc{color:#fff;font-size:.9rem;line-height:1.6;margin-top:8px}.card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:16px;padding:20px 22px}.card-label{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#fff;margin-bottom:10px}.url-box{font-family:'SF Mono','Fira Code',monospace;font-size:.8rem;color:#fff;background:rgba(0,0,0,.25);border:1px solid rgba(255,0,0,.12);border-radius:10px;padding:11px 14px;word-break:break-all;line-height:1.5}.actions{display:flex;gap:10px;margin-top:14px;flex-wrap:wrap}.btn{padding:10px 22px;border-radius:10px;font-size:.84rem;font-weight:600;text-decoration:none;border:none;cursor:pointer;transition:all .15s;font-family:inherit}.btn-install{background:#F00;color:#fff}.btn-install:hover{background:#c00}.btn-copy{background:#fff;color:#050810;border:1px solid #fff}.btn-copy:hover{background:#e6e6e6}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.stat{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.06);border-radius:12px;padding:16px 12px;text-align:center}.stat-val{font-size:1.5rem;font-weight:800;color:#fff;line-height:1;margin-bottom:5px}.stat-lbl{font-size:.68rem;color:#aaa;font-weight:600;text-transform:uppercase;letter-spacing:.5px}.made-by{text-align:center;color:#fff;font-size:.85rem;margin-top:6px;opacity:.8}</style></head><body><div class="wrap"><div><div class="logo"><span class="van">Van</span> <span class="streams">Streams+</span></div><div class="badge-row"><span class="badge">🎬 Movies</span><span class="badge">πŸ“Ί Series</span></div><p class="desc">Get Your 🍿 Ready</p></div><div class="card"><div class="card-label">Stremio Install URL</div><div class="url-box">${manifestUrl}</div><div class="actions"><a href="${installUrl}" class="btn btn-install">Install in Stremio</a><button class="btn btn-copy" onclick="navigator.clipboard.writeText('${manifestUrl}').then(()=>{this.textContent='βœ” Copied';setTimeout(()=>this.textContent='Copy URL',2000)})">Copy URL</button></div></div><div class="stats"><div class="stat"><div class="stat-val">100K+</div><div class="stat-lbl">Movies</div></div><div class="stat"><div class="stat-val">40K+</div><div class="stat-lbl">Series</div></div><div class="stat"><div class="stat-val">20K+</div><div class="stat-lbl">Anime</div></div></div><div class="made-by">Made With 🀍 By Van</div></div></body></html>`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  });
229
 
230
- app.listen(PORT, '0.0.0.0', () => console.log('VanStreams+ Script1 [torrentsdb+strem.fun] on :' + PORT));
 
1
  'use strict';
2
+
3
  const express = require('express');
4
  const axios = require('axios');
5
+
6
  const app = express();
7
  const PORT = process.env.PORT || 7860;
8
 
 
16
 
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 TORRENTIO_BASE = 'https://torrentio.strem.fun';
20
+
21
+ const PROXY_1 = 'https://corsproxy.io/?url=';
22
+ const PROXY_2 = 'https://api.codetabs.com/v1/proxy?quest=';
23
+ const PROXY_3 = 'https://thingproxy.freeboard.io/fetch/';
24
 
25
  const MAX_SIZE_GB_MOVIE = 4;
26
  const MAX_SIZE_GB_SERIES = 1.5;
27
  const LINKS_PER_QUALITY = 5;
28
  const ALLOWED_QUALITIES = ['4k', '1080p', '720p', '576p', '480p', 'webrip'];
29
+
30
  const QUALITY_RANK = {
31
  '1080p': 1, '720p': 2, '576p': 3, '480p': 4,
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',
39
+ 'knaben',
40
+ 'thepiratesbay',
41
+ 'thepiratebay',
42
+ 'eztv',
43
+ 'torrentcsv',
44
+ 'nyaa',
45
+ 'nyaasi',
46
+ 'limetorrent',
47
+ 'kickasstorrents',
48
+ 'animetosho',
49
+ 'tokyotosho'
50
  ];
51
 
52
  const TR = [
 
62
  'https://tracker.zhuqiy.com:443/announce',
63
  ].map(t => '&tr=' + encodeURIComponent(t)).join('');
64
 
65
+ // ─── Proxy fallback function ────────────────────────────────────────
66
+ async function fetchWithProxy(url, options = {}) {
67
+ const proxies = [
68
+ url,
69
+ PROXY_1 + encodeURIComponent(url),
70
+ PROXY_2 + encodeURIComponent(url),
71
+ PROXY_3 + encodeURIComponent(url),
72
+ ];
73
+
74
+ for (const proxyUrl of proxies) {
75
+ try {
76
+ const response = await axios.get(proxyUrl, { timeout: 12000, headers: { 'User-Agent': UA }, ...options });
77
+ return response;
78
+ } catch (e) {
79
+ console.warn(`[Proxy] Failed: ${proxyUrl.substring(0, 50)}... Error: ${e.message}`);
80
+ continue;
81
+ }
82
+ }
83
+ throw new Error('All proxy attempts failed');
84
+ }
85
+
86
  function buildMagnet(hash, name) {
87
  return 'magnet:?xt=urn:btih:' + hash.toLowerCase() + '&dn=' + encodeURIComponent(name) + TR;
88
  }
89
+
90
  function getQuality(str = '') {
91
  const s = str.toLowerCase();
92
  if (s.includes('4k') || s.includes('2160p') || s.includes('uhd')) return '4k';
 
98
  if (s.includes('webdl') || s.includes('web-dl')) return 'webdl';
99
  return null;
100
  }
101
+
102
  function getSizeGB(stream) {
103
  const raw = stream.title || stream.name || '';
104
  const m = raw.match(/πŸ’Ύ\s*([\d.]+)\s*(GB|MB)/i) || raw.match(/([\d.]+)\s*(GB|MB)/i);
 
106
  const val = parseFloat(m[1]);
107
  return m[2].toUpperCase() === 'GB' ? val : val / 1024;
108
  }
109
+
110
  function getSeeders(stream) {
111
  if (stream._seeders != null) return stream._seeders;
112
  if (stream.behaviorHints?.seeders) return stream.behaviorHints.seeders;
 
114
  return m ? parseInt(m[1], 10) : 0;
115
  }
116
 
117
+ function getLangLine(str = '') {
118
+ const lower = str.toLowerCase();
119
+ const found = [];
120
+ const checks = [
121
+ [/\benglish\b/, 'English'],
122
+ [/\bjapanese\b/, 'Japanese'],
123
+ [/\bhindi\b/, 'Hindi'],
124
+ [/\bfrench\b/, 'French'],
125
+ [/\bgerman\b/, 'German'],
126
+ [/\bspanish\b/, 'Spanish'],
127
+ [/\bitalian\b/, 'Italian'],
128
+ [/\brussian\b/, 'Russian'],
129
+ [/\bkorean\b/, 'Korean'],
130
+ [/\bchinese\b/, 'Chinese'],
131
+ [/\barabic\b/, 'Arabic'],
132
+ [/\bportuguese\b/, 'Portuguese'],
133
+ [/\bturkish\b/, 'Turkish'],
134
+ [/\bpolish\b/, 'Polish'],
135
+ [/\bdutch\b/, 'Dutch'],
136
+ [/\bczech\b/, 'Czech'],
137
+ [/\bswedish\b/, 'Swedish'],
138
+ [/\bnorwegian\b/, 'Norwegian'],
139
+ [/\bdanish\b/, 'Danish'],
140
+ [/\bfinnish\b/, 'Finnish'],
141
+ [/\bromanian\b/, 'Romanian'],
142
+ [/\bgreek\b/, 'Greek'],
143
+ [/\bhebrew\b/, 'Hebrew'],
144
+ [/\bthai\b/, 'Thai'],
145
+ [/\bindonesian\b/, 'Indonesian'],
146
+ [/\bvietnamese\b/, 'Vietnamese'],
147
+ [/\btamil\b/, 'Tamil'],
148
+ [/\btelugu\b/, 'Telugu'],
149
+ [/\burdu\b/, 'Urdu'],
150
+ [/\bdubbed\b/, 'Dubbed'],
151
+ [/\bdual[\s\-]audio\b/, 'Dual Audio'],
152
+ [/\bmulti[\s\-]audio\b/, 'Multi Audio'],
153
+ [/\bmulti\b/, 'Multi'],
154
+ ];
155
+ for (const [re, label] of checks) {
156
+ if (re.test(lower)) {
157
+ if (label === 'Multi' && found.includes('Multi Audio')) continue;
158
+ found.push(label);
159
+ }
160
+ }
161
+ return [...new Set(found)].join(' / ');
162
+ }
163
+
164
  function processStreams(streams, type) {
165
  const maxSize = type === 'series' ? MAX_SIZE_GB_SERIES : MAX_SIZE_GB_MOVIE;
166
+
167
  const filtered = streams.filter(s => {
168
  if (!s.infoHash && !s.url) return false;
169
  if (s._provider && !ALLOWED_PROVIDERS.includes(s._provider.toLowerCase())) return false;
 
173
  if (sizeGB !== null && sizeGB > maxSize) return false;
174
  return true;
175
  });
176
+
177
  const seen = new Set();
178
  const unique = filtered.filter(s => {
179
  const key = (s.infoHash || '').toLowerCase();
180
  if (!key) return true;
181
  if (seen.has(key)) return false;
182
+ seen.add(key);
183
+ return true;
184
  });
185
+
186
  const byQuality = {};
187
  unique.forEach(s => {
188
  const q = s._quality || getQuality(s.title || s.name || '') || 'unknown';
189
  if (!byQuality[q]) byQuality[q] = [];
190
  byQuality[q].push(s);
191
  });
192
+
193
  for (const q in byQuality) {
194
  byQuality[q].sort((a, b) => {
195
  const rankA = PRIORITY_PROVIDERS.indexOf((a._source || '').toLowerCase());
 
200
  return getSeeders(b) - getSeeders(a);
201
  });
202
  }
203
+
204
+ const sortedQualities = Object.keys(byQuality).sort(
205
+ (a, b) => (QUALITY_RANK[a] || 99) - (QUALITY_RANK[b] || 99)
206
+ );
207
+
208
  const result = [];
209
  sortedQualities.forEach(q => result.push(...byQuality[q].slice(0, LINKS_PER_QUALITY)));
210
  return result;
 
224
  } catch (e) { console.error('[TMDB]', e.message); return null; }
225
  }
226
 
227
+ async function scrapeYTS(imdbId) {
228
+ try {
229
+ const { data } = await axios.get(
230
+ `https://movies-api.accel.li/api/v2/list_movies.json?query_term=${imdbId}&limit=10`,
231
+ { timeout: 12000, headers: { 'User-Agent': UA } }
232
+ );
233
+ if (data.status !== 'ok' || !data.data?.movies?.length) return [];
234
+ const streams = [];
235
+ for (const movie of data.data.movies) {
236
+ if (movie.imdb_code && movie.imdb_code !== imdbId) continue;
237
+ for (const t of (movie.torrents || [])) {
238
+ if (!t.hash) continue;
239
+ const qualityStr = `${t.quality} ${t.type || ''}`.trim();
240
+ const sizeGB = t.size_bytes ? t.size_bytes / 1073741824 : null;
241
+ const sizeStr = sizeGB ? sizeGB.toFixed(2) + ' GB' : (t.size || '');
242
+ const langLine = getLangLine(qualityStr);
243
+ streams.push({
244
+ infoHash: t.hash.toLowerCase(),
245
+ name: qualityStr,
246
+ title: `β˜€οΈ ${movie.title} (${movie.year})\n🌱 ${t.seeds || 0}\nπŸ’Ύ ${sizeStr}\nπŸ… YTS${langLine ? '\nπŸ”Š ' + langLine : ''}`,
247
+ sources: [],
248
+ _quality: getQuality(qualityStr),
249
+ _seeders: t.seeds || 0,
250
+ _sizeGB: sizeGB,
251
+ _source: 'yts',
252
+ _provider: 'yts',
253
+ });
254
+ }
255
+ }
256
+ console.log(`[YTS] ${streams.length} for ${imdbId}`);
257
+ return streams;
258
+ } catch (e) { console.error('[YTS]', e.message); return []; }
259
  }
260
 
261
+ async function scrapeKnaben(title, year, isSeries, season, episode) {
262
+ if (!title) return [];
263
  try {
264
+ let query = title;
265
+ if (year && !isSeries) query += ' ' + year;
266
+ if (isSeries && season && episode)
267
+ query += ` S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`;
268
+ const categories = isSeries ? [5000000, 5001000] : [2000000, 2001000];
269
+ const { data } = await axios.post(
270
+ 'https://api.knaben.org/v1',
271
+ { search_type: '75%', search_field: 'title', query, order_by: 'seeders', order_direction: 'desc', categories, from: 0, size: 40, hide_unsafe: true, hide_xxx: true },
272
+ { timeout: 14000, headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA } }
273
+ );
274
+ if (!data?.hits?.length) return [];
275
+ const streams = [];
276
+ for (const hit of data.hits) {
277
+ if (!hit.magnetUrl && !hit.hash) continue;
278
+ const magnet = hit.magnetUrl || buildMagnet(hit.hash, hit.title || '');
279
+ const hashMatch = magnet.match(/btih:([a-fA-F0-9]{32,40})/i);
280
+ const hash = hashMatch ? hashMatch[1] : (hit.hash || '');
281
+ if (!hash) continue;
282
+ const sizeGB = hit.bytes ? hit.bytes / 1073741824 : null;
283
+ const sizeStr = sizeGB ? sizeGB.toFixed(2) + ' GB' : '';
284
+ const langLine = getLangLine(hit.title || '');
285
+ streams.push({
286
+ infoHash: hash.toLowerCase(),
287
+ name: hit.title || '',
288
+ title: `β˜€οΈ ${title}${year ? ' (' + year + ')' : ''}\n🌱 ${hit.seeders || 0}\nπŸ’Ύ ${sizeStr}\nπŸ… Knaben${langLine ? '\nπŸ”Š ' + langLine : ''}`,
289
+ sources: [],
290
+ _quality: getQuality(hit.title || ''),
291
+ _seeders: hit.seeders || 0,
292
+ _sizeGB: sizeGB,
293
+ _source: 'knaben',
294
+ _provider: 'knaben',
295
+ });
296
+ }
297
+ console.log(`[Knaben] ${streams.length} for "${query}"`);
298
  return streams;
299
+ } catch (e) { console.error('[Knaben]', e.message); return []; }
300
  }
301
 
302
+ async function fetchTorrentio(type, id, title, year) {
 
303
  try {
304
+ const url = `${TORRENTIO_BASE}/stream/${type}/${id}.json`;
305
+ const { data } = await fetchWithProxy(url);
306
+ const streams = (data.streams || []).map(s => {
307
+ const origTitle = s.title || '';
308
+ const providerMatch = (s.title || '').match(/βš™οΈ\s*(\S+)/);
309
+ let provider = providerMatch ? providerMatch[1] : 'Unknown';
310
+ provider = provider.toLowerCase().replace(/\.(to|com|org|net|io)$/, '');
311
+ if (provider === 'thepiratebay') provider = 'thepiratesbay';
312
+ if (provider === 'nyaa.si') provider = 'nyaa';
313
+ if (provider === 'limetorrents') provider = 'limetorrent';
314
+ if (provider === 'kat') provider = 'kickasstorrents';
315
+ const seeders = s.behaviorHints?.seeders
316
+ ?? (() => { const m = (s.title || '').match(/πŸ‘€\s*(\d+)/); return m ? parseInt(m[1], 10) : 0; })();
317
+ const sizeGB = getSizeGB(s);
318
+ const langLine = getLangLine(origTitle);
319
+ return {
320
+ ...s,
321
+ title: `β˜€οΈ ${title}${year ? ' (' + year + ')' : ''}\n🌱 ${seeders}\nπŸ’Ύ ${sizeGB ? sizeGB.toFixed(2) + ' GB' : 'N/A'}\nπŸ… ${provider}${langLine ? '\nπŸ”Š ' + langLine : ''}`,
322
+ _quality: getQuality(s.title || s.name || ''),
323
+ _seeders: seeders,
324
+ _sizeGB: sizeGB,
325
+ _source: 'torrentio',
326
+ _provider: provider,
327
+ };
328
+ });
329
+ console.log(`[Torrentio] ${streams.length} for ${type}/${id}`);
330
  return streams;
331
+ } catch (e) { console.error('[Torrentio]', e.message); return []; }
332
  }
333
 
334
+ // ─── Manifest ──────────────────────────────────────────────────────────
335
+ app.get('/manifest.json', (_, res) => {
336
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
337
+ res.json({
338
+ id: 'org.ghostream.plus.v2',
339
+ name: 'VanStreams+',
340
+ description: 'Stream Everything You Want\nMade With 🀍 By Van',
341
+ version: '15.0.7',
342
+ resources: ['stream'],
343
+ types: ['movie', 'series'],
344
+ idPrefixes: ['tt'],
345
+ catalogs: [],
346
+ });
347
+ });
348
 
349
+ // ─── Stream handler ────────────────────────────────────────────────
350
  app.get('/stream/:type/:id.json', async (req, res) => {
351
  const { type, id } = req.params;
352
+ const isSeries = type === 'series';
353
+ const [imdbId, season = '', episode = ''] = id.split(':');
354
+
355
  const meta = await tmdbLookup(imdbId);
356
  const title = meta?.title || '';
357
  const year = meta?.year || '';
358
 
359
+ const [ytsR, knabenR, torrentioR] = await Promise.allSettled([
360
+ isSeries ? Promise.resolve([]) : scrapeYTS(imdbId),
361
+ scrapeKnaben(title, year, isSeries, season, episode),
362
  fetchTorrentio(type, id, title, year),
 
363
  ]);
 
 
364
 
365
+ const yts = ytsR.status === 'fulfilled' ? (ytsR.value || []) : [];
366
+ const knaben = knabenR.status === 'fulfilled' ? (knabenR.value || []) : [];
367
+ const torrentio = torrentioR.status === 'fulfilled' ? (torrentioR.value || []) : [];
368
+
369
+ const streams = processStreams([...yts, ...knaben, ...torrentio], type);
370
+
371
+ console.log(`[stream] ${type}/${id} β†’ yts:${yts.length} knaben:${knaben.length} torrentio:${torrentio.length} final:${streams.length}`);
372
  res.json({ streams });
373
  });
374
 
375
+ // ─── Diagnostic endpoints ─────────────────────────────────────────────
376
  app.get('/diag/:type/:id', async (req, res) => {
377
  const { type, id } = req.params;
378
+ const isSeries = type === 'series';
379
+ const [imdbId, season = '', episode = ''] = id.split(':');
380
  const meta = await tmdbLookup(imdbId);
381
+ const [ytsR, knabenR, torrentioR] = await Promise.allSettled([
382
+ isSeries ? Promise.resolve([]) : scrapeYTS(imdbId),
383
+ scrapeKnaben(meta?.title || '', meta?.year || '', isSeries, season, episode),
384
  fetchTorrentio(type, id, meta?.title || '', meta?.year || ''),
 
385
  ]);
386
  res.json({
387
+ imdbId, title: meta?.title, year: meta?.year, isSeries,
388
+ tmdb: meta ? `OK: ${meta.title} (${meta.year})` : 'FAILED',
389
+ yts: { count: (ytsR.value || []).length, err: ytsR.reason?.message || null },
390
+ knaben: { count: (knabenR.value || []).length, err: knabenR.reason?.message || null },
391
+ torrentio: { count: (torrentioR.value || []).length, err: torrentioR.reason?.message || null },
392
  });
393
  });
394
 
395
  app.get('/diag-raw/:type/:id', async (req, res) => {
396
  const { type, id } = req.params;
397
  try {
398
+ const url = `${TORRENTIO_BASE}/stream/${type}/${id}.json`;
399
+ const { data } = await fetchWithProxy(url);
400
+ res.json((data.streams || []).slice(0, 3).map(s => ({
401
+ name: s.name, title: s.title, behaviorHints: s.behaviorHints,
402
+ })));
403
  } catch (e) { res.json({ error: e.message }); }
404
  });
405
 
406
+ // ══════════════════════════ LANDING PAGE ═══════════════════════════
407
  app.get('/', (req, res) => {
408
+ const manifestUrl = 'https://vanseleem-vsplus.hf.space/manifest.json';
409
+ const installUrl = 'stremio://vanseleem-vsplus.hf.space/manifest.json';
410
+
411
+ res.type('html').send(`<!DOCTYPE html>
412
+ <html lang="en">
413
+ <head>
414
+ <meta charset="UTF-8">
415
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
416
+ <title>VanStreams+</title>
417
+ <style>
418
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
419
+ body {
420
+ font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
421
+ background: #050810;
422
+ color: #dde6f0;
423
+ min-height: 100vh;
424
+ display: flex;
425
+ align-items: center;
426
+ justify-content: center;
427
+ padding: 28px 20px;
428
+ background-image:
429
+ radial-gradient(ellipse 70% 40% at 50% 0%, rgba(255,0,0,0.05) 0%, transparent 70%),
430
+ radial-gradient(ellipse 40% 30% at 80% 80%, rgba(255,0,0,0.03) 0%, transparent 60%);
431
+ }
432
+ .wrap { width: 100%; max-width: 560px; display: flex; flex-direction: column; gap: 20px; }
433
+ .header { display: flex; flex-direction: column; gap: 12px; }
434
+ .logo { font-size: 2.6rem; font-weight: 800; letter-spacing: -1px; line-height: 1; }
435
+ .logo .van { color: #FF0000; }
436
+ .logo .streams { color: #ffffff; }
437
+ .badge-row { display: flex; gap: 8px; flex-wrap: wrap; }
438
+ .badge {
439
+ display: inline-flex; align-items: center; gap: 5px;
440
+ background: rgba(255,0,0,0.08); border: 1px solid rgba(255,0,0,0.18);
441
+ color: #ffffff; padding: 4px 11px; border-radius: 100px;
442
+ font-size: 0.72rem; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase;
443
+ }
444
+ .badge.series { color: #ffffff; }
445
+ .desc { color: #ffffff; font-size: 0.9rem; line-height: 1.6; margin-top: 2px; }
446
+ .card {
447
+ background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
448
+ border-radius: 16px; padding: 20px 22px;
449
+ }
450
+ .card-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #ffffff; margin-bottom: 10px; }
451
+ .url-box {
452
+ font-family: 'SF Mono','Fira Code','Consolas',monospace; font-size: 0.8rem;
453
+ color: #ffffff; background: rgba(0,0,0,0.25); border: 1px solid rgba(255,0,0,0.12);
454
+ border-radius: 10px; padding: 11px 14px; word-break: break-all; line-height: 1.5;
455
+ }
456
+ .actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
457
+ .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; }
458
+ .btn-install {
459
+ background: #FF0000;
460
+ color: #ffffff;
461
+ }
462
+ .btn-install:hover { background: #cc0000; }
463
+ .btn-copy {
464
+ background: #ffffff;
465
+ color: #050810;
466
+ border: 1px solid #ffffff;
467
+ }
468
+ .btn-copy:hover {
469
+ background: #e6e6e6;
470
+ border-color: #e6e6e6;
471
+ }
472
+ .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
473
+ .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; }
474
+ .stat-val { font-size: 1.5rem; font-weight: 800; color: #fff; line-height: 1; margin-bottom: 5px; }
475
+ .stat-lbl { font-size: 0.68rem; color: #aaa; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; white-space: pre-line; }
476
+ .made-by { text-align: center; color: #ffffff; font-size: 0.85rem; margin-top: 6px; opacity: 0.8; }
477
+ </style>
478
+ </head>
479
+ <body>
480
+ <div class="wrap">
481
+ <div class="header">
482
+ <div class="logo">
483
+ <span class="van">Van</span> <span class="streams">Streams+</span>
484
+ </div>
485
+ <div class="badge-row">
486
+ <span class="badge">🎬 Movies</span>
487
+ <span class="badge series">πŸ“Ί Series</span>
488
+ </div>
489
+ <p class="desc">Get Your 🍿 Ready</p>
490
+ </div>
491
+ <div class="card">
492
+ <div class="card-label">Stremio Install URL</div>
493
+ <div class="url-box">${manifestUrl}</div>
494
+ <div class="actions">
495
+ <a href="${installUrl}" class="btn btn-install">Install in Stremio</a>
496
+ <button class="btn btn-copy" onclick="navigator.clipboard.writeText('${manifestUrl}').then(()=>{this.textContent='βœ” Copied';setTimeout(()=>this.textContent='Copy URL',2000)})">Copy URL</button>
497
+ </div>
498
+ </div>
499
+ <div class="stats">
500
+ <div class="stat"><div class="stat-val">100K+</div><div class="stat-lbl">Movies</div></div>
501
+ <div class="stat"><div class="stat-val">40K+</div><div class="stat-lbl">Series</div></div>
502
+ <div class="stat"><div class="stat-val">20K+</div><div class="stat-lbl">Anime</div></div>
503
+ </div>
504
+ <div class="made-by">Made With 🀍 By Van</div>
505
+ </div>
506
+ </body>
507
+ </html>`);
508
  });
509
 
510
+ app.listen(PORT, '0.0.0.0', () => console.log('VanStreams+ 🎬 v7.0.5 on :' + PORT));