vanseleem commited on
Commit
6a8a603
Β·
verified Β·
1 Parent(s): 6eb1c9c

Create Knaben + Torrentio + Torrents DB

Browse files
Files changed (1) hide show
  1. Knaben + Torrentio + Torrents DB +434 -0
Knaben + Torrentio + Torrents DB ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
9
+ app.use((req, res, next) => {
10
+ res.header('Access-Control-Allow-Origin', '*');
11
+ res.header('Access-Control-Allow-Headers', '*');
12
+ res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
13
+ if (req.method === 'OPTIONS') return res.sendStatus(200);
14
+ next();
15
+ });
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_MAIN = 'https://torrentio.strem.fun';
20
+ const TORRENTIO_FALLBACK = 'https://torrentsdb.com';
21
+
22
+ const MAX_SIZE_GB_MOVIE = 4;
23
+ const MAX_SIZE_GB_SERIES = 1.5;
24
+ const LINKS_PER_QUALITY = 5;
25
+ const ALLOWED_QUALITIES = ['4k', '1080p', '720p', '576p', '480p', 'webrip'];
26
+
27
+ const QUALITY_RANK = {
28
+ '1080p': 1, '720p': 2, '576p': 3, '480p': 4,
29
+ '4k': 5, '2160p': 5, 'webrip': 6, 'webdl': 6,
30
+ };
31
+
32
+ // βœ… FIX: names must exactly match the normalized _provider strings set in mapTorrentioStreams/scrapeKnaben
33
+ // To change order, just reorder this array β€” index 0 = highest priority
34
+ const PRIORITY_PROVIDERS = [
35
+ 'yts', // YTS
36
+ 'eztv', // EZTV
37
+ 'nyaa', // NyaaSi (normalized from nyaa.si)
38
+ 'animetosho', // AnimeTosho
39
+ 'knaben', // Knaben
40
+ 'thepiratesbay', // The Pirate Bay (normalized from thepiratebay)
41
+ 'limetorrent', // LimeTorrent (normalized from limetorrents)
42
+ 'torrentcsv',
43
+ 'kickasstorrents',
44
+ 'tokyotosho',
45
+ ];
46
+
47
+ const ALLOWED_PROVIDERS = [
48
+ 'yts',
49
+ 'knaben',
50
+ 'thepiratesbay',
51
+ 'thepiratebay',
52
+ 'eztv',
53
+ 'torrentcsv',
54
+ 'nyaa',
55
+ 'nyaasi',
56
+ 'limetorrent',
57
+ 'kickasstorrents',
58
+ 'animetosho',
59
+ 'tokyotosho',
60
+ ];
61
+
62
+ const TR = [
63
+ 'udp://tracker.opentrackr.org:1337/announce',
64
+ 'udp://open.stealth.si:80/announce',
65
+ 'udp://open.demonii.com:1337/announce',
66
+ 'udp://tracker.torrent.eu.org:451/announce',
67
+ 'udp://p4p.arenabg.com:1337/announce',
68
+ 'udp://exodus.desync.com:6969/announce',
69
+ 'udp://tracker.openbittorrent.com:6969/announce',
70
+ 'udp://tracker.dler.org:6969/announce',
71
+ 'https://tracker.moeblog.cn:443/announce',
72
+ 'https://tracker.zhuqiy.com:443/announce',
73
+ ].map(t => '&tr=' + encodeURIComponent(t)).join('');
74
+
75
+ function buildMagnet(hash, name) {
76
+ return 'magnet:?xt=urn:btih:' + hash.toLowerCase() + '&dn=' + encodeURIComponent(name) + TR;
77
+ }
78
+
79
+ function getQuality(str = '') {
80
+ const s = str.toLowerCase();
81
+ if (s.includes('4k') || s.includes('2160p') || s.includes('uhd')) return '4k';
82
+ if (s.includes('1080p')) return '1080p';
83
+ if (s.includes('720p')) return '720p';
84
+ if (s.includes('576p')) return '576p';
85
+ if (s.includes('480p')) return '480p';
86
+ if (s.includes('webrip')) return 'webrip';
87
+ if (s.includes('webdl') || s.includes('web-dl')) return 'webdl';
88
+ return null;
89
+ }
90
+
91
+ function getSizeGB(stream) {
92
+ const raw = stream.title || stream.name || '';
93
+ const m = raw.match(/πŸ’Ύ\s*([\d.]+)\s*(GB|MB)/i) || raw.match(/([\d.]+)\s*(GB|MB)/i);
94
+ if (!m) return null;
95
+ const val = parseFloat(m[1]);
96
+ return m[2].toUpperCase() === 'GB' ? val : val / 1024;
97
+ }
98
+
99
+ function getSeeders(stream) {
100
+ if (stream._seeders != null) return stream._seeders;
101
+ if (stream.behaviorHints?.seeders) return stream.behaviorHints.seeders;
102
+ const m = (stream.title || '').match(/πŸ‘€\s*(\d+)/);
103
+ return m ? parseInt(m[1], 10) : 0;
104
+ }
105
+
106
+ function normalizeProvider(raw = '') {
107
+ let p = raw.toLowerCase().trim();
108
+ // Strip common TLDs
109
+ p = p.replace(/\.(to|com|org|net|io|si)$/, '');
110
+ // Aliases
111
+ if (p === 'thepiratebay' || p === 'tpb') p = 'thepiratesbay';
112
+ if (p === 'nyaasi' || p === 'nyaa') p = 'nyaa';
113
+ if (p === 'limetorrents') p = 'limetorrent';
114
+ if (p === 'kat') p = 'kickasstorrents';
115
+ return p;
116
+ }
117
+
118
+ function processStreams(streams, type) {
119
+ const maxSize = type === 'series' ? MAX_SIZE_GB_SERIES : MAX_SIZE_GB_MOVIE;
120
+
121
+ const filtered = streams.filter(s => {
122
+ if (!s.infoHash && !s.url) return false;
123
+ if (s._provider && !ALLOWED_PROVIDERS.includes(s._provider.toLowerCase())) return false;
124
+ const q = s._quality || getQuality(s.title || s.name || '');
125
+ if (!q || !ALLOWED_QUALITIES.includes(q)) return false;
126
+ const sizeGB = s._sizeGB ?? getSizeGB(s);
127
+ if (sizeGB !== null && sizeGB > maxSize) return false;
128
+ return true;
129
+ });
130
+
131
+ const seen = new Set();
132
+ const unique = filtered.filter(s => {
133
+ const key = (s.infoHash || '').toLowerCase();
134
+ if (!key) return true;
135
+ if (seen.has(key)) return false;
136
+ seen.add(key);
137
+ return true;
138
+ });
139
+
140
+ const byQuality = {};
141
+ unique.forEach(s => {
142
+ const q = s._quality || getQuality(s.title || s.name || '') || 'unknown';
143
+ if (!byQuality[q]) byQuality[q] = [];
144
+ byQuality[q].push(s);
145
+ });
146
+
147
+ for (const q in byQuality) {
148
+ byQuality[q].sort((a, b) => {
149
+ // βœ… FIX: use _provider (not _source) for priority lookup
150
+ const rankA = PRIORITY_PROVIDERS.indexOf((a._provider || '').toLowerCase());
151
+ const rankB = PRIORITY_PROVIDERS.indexOf((b._provider || '').toLowerCase());
152
+ const pA = rankA === -1 ? 999 : rankA;
153
+ const pB = rankB === -1 ? 999 : rankB;
154
+ if (pA !== pB) return pA - pB;
155
+ // Tiebreak by seeders
156
+ return getSeeders(b) - getSeeders(a);
157
+ });
158
+ }
159
+
160
+ const sortedQualities = Object.keys(byQuality).sort(
161
+ (a, b) => (QUALITY_RANK[a] || 99) - (QUALITY_RANK[b] || 99)
162
+ );
163
+
164
+ const result = [];
165
+ sortedQualities.forEach(q => result.push(...byQuality[q].slice(0, LINKS_PER_QUALITY)));
166
+ return result;
167
+ }
168
+
169
+ async function tmdbLookup(imdbId) {
170
+ try {
171
+ const { data } = await axios.get(
172
+ `https://api.themoviedb.org/3/find/${imdbId}?api_key=${TMDB_KEY}&external_source=imdb_id`,
173
+ { timeout: 8000 }
174
+ );
175
+ const movie = data.movie_results?.[0];
176
+ const tv = data.tv_results?.[0];
177
+ if (movie) return { title: movie.title || movie.original_title, year: (movie.release_date || '').slice(0, 4) };
178
+ if (tv) return { title: tv.name || tv.original_name, year: (tv.first_air_date || '').slice(0, 4) };
179
+ return null;
180
+ } catch (e) { console.error('[TMDB]', e.message); return null; }
181
+ }
182
+
183
+ async function scrapeKnaben(title, year, isSeries, season, episode) {
184
+ if (!title) return [];
185
+ try {
186
+ let query = title;
187
+ if (year && !isSeries) query += ' ' + year;
188
+ if (isSeries && season && episode)
189
+ query += ` S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`;
190
+ const categories = isSeries ? [5000000, 5001000] : [2000000, 2001000];
191
+ const { data } = await axios.post(
192
+ 'https://api.knaben.org/v1',
193
+ { search_type: '75%', search_field: 'title', query, order_by: 'seeders', order_direction: 'desc', categories, from: 0, size: 40, hide_unsafe: true, hide_xxx: true },
194
+ { timeout: 14000, headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA } }
195
+ );
196
+ if (!data?.hits?.length) return [];
197
+ const streams = [];
198
+ for (const hit of data.hits) {
199
+ if (!hit.magnetUrl && !hit.hash) continue;
200
+ const magnet = hit.magnetUrl || buildMagnet(hit.hash, hit.title || '');
201
+ const hashMatch = magnet.match(/btih:([a-fA-F0-9]{32,40})/i);
202
+ const hash = hashMatch ? hashMatch[1] : (hit.hash || '');
203
+ if (!hash) continue;
204
+ const sizeGB = hit.bytes ? hit.bytes / 1073741824 : null;
205
+ const sizeStr = sizeGB ? sizeGB.toFixed(2) + ' GB' : '';
206
+ streams.push({
207
+ infoHash: hash.toLowerCase(),
208
+ name: hit.title || '',
209
+ title: `β˜€οΈ ${title}${year ? ' (' + year + ')' : ''}\n🌱 ${hit.seeders || 0}\nπŸ’Ύ ${sizeStr}\nπŸ… Knaben`,
210
+ sources: [],
211
+ _quality: getQuality(hit.title || ''),
212
+ _seeders: hit.seeders || 0,
213
+ _sizeGB: sizeGB,
214
+ _source: 'knaben',
215
+ _provider: 'knaben', // βœ… normalized provider name
216
+ });
217
+ }
218
+ console.log(`[Knaben] ${streams.length} for "${query}"`);
219
+ return streams;
220
+ } catch (e) { console.error('[Knaben]', e.message); return []; }
221
+ }
222
+
223
+ function mapTorrentioStreams(rawStreams, title, year) {
224
+ return rawStreams.map(s => {
225
+ const providerMatch = (s.title || '').match(/βš™οΈ\s*(\S+)/);
226
+ const rawProvider = providerMatch ? providerMatch[1] : 'unknown';
227
+ // βœ… FIX: use shared normalizeProvider() so names always match PRIORITY_PROVIDERS
228
+ const provider = normalizeProvider(rawProvider);
229
+
230
+ const seeders = s.behaviorHints?.seeders
231
+ ?? (() => { const m = (s.title || '').match(/πŸ‘€\s*(\d+)/); return m ? parseInt(m[1], 10) : 0; })();
232
+ const sizeGB = getSizeGB(s);
233
+
234
+ return {
235
+ ...s,
236
+ title: `β˜€οΈ ${title}${year ? ' (' + year + ')' : ''}\n🌱 ${seeders}\nπŸ’Ύ ${sizeGB ? sizeGB.toFixed(2) + ' GB' : 'N/A'}\nπŸ… ${provider}`,
237
+ _quality: getQuality(s.title || s.name || ''),
238
+ _seeders: seeders,
239
+ _sizeGB: sizeGB,
240
+ _source: 'torrentio',
241
+ _provider: provider, // βœ… normalized provider name
242
+ };
243
+ });
244
+ }
245
+
246
+ async function fetchTorrentioStremFun(type, id, title, year) {
247
+ try {
248
+ const url = `${TORRENTIO_MAIN}/stream/${type}/${id}.json`;
249
+ const { data } = await axios.get(url, {
250
+ timeout: 20000,
251
+ headers: { 'User-Agent': UA },
252
+ });
253
+ const streams = mapTorrentioStreams(data.streams || [], title, year);
254
+ console.log(`[StremFun] ${streams.length} for ${type}/${id}`);
255
+ return streams;
256
+ } catch (e) { console.error('[StremFun]', e.message); return []; }
257
+ }
258
+
259
+ async function fetchTorrentsDB(type, id, title, year) {
260
+ try {
261
+ const url = `${TORRENTIO_FALLBACK}/stream/${type}/${id}.json`;
262
+ const { data } = await axios.get(url, { timeout: 12000, headers: { 'User-Agent': UA } });
263
+ const streams = mapTorrentioStreams(data.streams || [], title, year);
264
+ console.log(`[TorrentsDB] ${streams.length} for ${type}/${id}`);
265
+ return streams;
266
+ } catch (e) { console.error('[TorrentsDB]', e.message); return []; }
267
+ }
268
+
269
+ // ─── Manifest ──────────────────────────────────────────────────────────
270
+ app.get('/manifest.json', (_, res) => {
271
+ res.json({
272
+ id: 'org.ghostream.plus.v2',
273
+ name: 'VanStreams+',
274
+ description: 'Stream Everything You Want\nMade With 🀍 By Van',
275
+ version: '15.0.7',
276
+ resources: ['stream'],
277
+ types: ['movie', 'series'],
278
+ idPrefixes: ['tt'],
279
+ catalogs: [],
280
+ });
281
+ });
282
+
283
+ // ─── Stream handler ────────────────────────────────────────────────────
284
+ app.get('/stream/:type/:id.json', async (req, res) => {
285
+ const { type, id } = req.params;
286
+ const isSeries = type === 'series';
287
+ const [imdbId, season = '', episode = ''] = id.split(':');
288
+
289
+ const meta = await tmdbLookup(imdbId);
290
+ const title = meta?.title || '';
291
+ const year = meta?.year || '';
292
+
293
+ const [knabenR, stremFunR, torrentsDBR] = await Promise.allSettled([
294
+ scrapeKnaben(title, year, isSeries, season, episode),
295
+ fetchTorrentioStremFun(type, id, title, year),
296
+ fetchTorrentsDB(type, id, title, year),
297
+ ]);
298
+
299
+ const knaben = knabenR.status === 'fulfilled' ? (knabenR.value || []) : [];
300
+ const stremFun = stremFunR.status === 'fulfilled' ? (stremFunR.value || []) : [];
301
+ const torrentsDB = torrentsDBR.status === 'fulfilled' ? (torrentsDBR.value || []) : [];
302
+
303
+ const streams = processStreams([...knaben, ...stremFun, ...torrentsDB], type);
304
+
305
+ console.log(`[stream] ${type}/${id} β†’ knaben:${knaben.length} stremfun:${stremFun.length} torrentsdb:${torrentsDB.length} final:${streams.length}`);
306
+ res.json({ streams });
307
+ });
308
+
309
+ // ─── Diagnostic endpoints ─────────────────────────────────────────────
310
+ app.get('/diag/:type/:id', async (req, res) => {
311
+ const { type, id } = req.params;
312
+ const isSeries = type === 'series';
313
+ const [imdbId, season = '', episode = ''] = id.split(':');
314
+ const meta = await tmdbLookup(imdbId);
315
+ const [knabenR, stremFunR, torrentsDBR] = await Promise.allSettled([
316
+ scrapeKnaben(meta?.title || '', meta?.year || '', isSeries, season, episode),
317
+ fetchTorrentioStremFun(type, id, meta?.title || '', meta?.year || ''),
318
+ fetchTorrentsDB(type, id, meta?.title || '', meta?.year || ''),
319
+ ]);
320
+ res.json({
321
+ imdbId, title: meta?.title, year: meta?.year, isSeries,
322
+ tmdb: meta ? `OK: ${meta.title} (${meta.year})` : 'FAILED',
323
+ knaben: { count: (knabenR.value || []).length, err: knabenR.reason?.message || null },
324
+ stremfun: { count: (stremFunR.value || []).length, err: stremFunR.reason?.message || null },
325
+ torrentsdb: { count: (torrentsDBR.value || []).length, err: torrentsDBR.reason?.message || null },
326
+ });
327
+ });
328
+
329
+ app.get('/diag-raw/:type/:id', async (req, res) => {
330
+ const { type, id } = req.params;
331
+ try {
332
+ const url = `${TORRENTIO_FALLBACK}/stream/${type}/${id}.json`;
333
+ const { data } = await axios.get(url, { timeout: 12000, headers: { 'User-Agent': UA } });
334
+ res.json((data.streams || []).slice(0, 3).map(s => ({
335
+ name: s.name, title: s.title, behaviorHints: s.behaviorHints,
336
+ })));
337
+ } catch (e) { res.json({ error: e.message }); }
338
+ });
339
+
340
+ // ══════════════════════════ LANDING PAGE ═══════════════════════════
341
+ app.get('/', (req, res) => {
342
+ const manifestUrl = 'https://vanseleem-vsplus.hf.space/manifest.json';
343
+ const installUrl = 'stremio://vanseleem-vsplus.hf.space/manifest.json';
344
+
345
+ res.type('html').send(`<!DOCTYPE html>
346
+ <html lang="en">
347
+ <head>
348
+ <meta charset="UTF-8">
349
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
350
+ <title>VanStreams+</title>
351
+ <style>
352
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
353
+ body {
354
+ font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
355
+ background: #050810;
356
+ color: #dde6f0;
357
+ min-height: 100vh;
358
+ display: flex;
359
+ align-items: center;
360
+ justify-content: center;
361
+ padding: 28px 20px;
362
+ background-image:
363
+ radial-gradient(ellipse 70% 40% at 50% 0%, rgba(255,0,0,0.05) 0%, transparent 70%),
364
+ radial-gradient(ellipse 40% 30% at 80% 80%, rgba(255,0,0,0.03) 0%, transparent 60%);
365
+ }
366
+ .wrap { width: 100%; max-width: 560px; display: flex; flex-direction: column; gap: 20px; }
367
+ .header { display: flex; flex-direction: column; gap: 12px; }
368
+ .logo { font-size: 2.6rem; font-weight: 800; letter-spacing: -1px; line-height: 1; }
369
+ .logo .van { color: #FF0000; }
370
+ .logo .streams { color: #ffffff; }
371
+ .badge-row { display: flex; gap: 8px; flex-wrap: wrap; }
372
+ .badge {
373
+ display: inline-flex; align-items: center; gap: 5px;
374
+ background: rgba(255,0,0,0.08); border: 1px solid rgba(255,0,0,0.18);
375
+ color: #ffffff; padding: 4px 11px; border-radius: 100px;
376
+ font-size: 0.72rem; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase;
377
+ }
378
+ .badge.series { color: #ffffff; }
379
+ .desc { color: #ffffff; font-size: 0.9rem; line-height: 1.6; margin-top: 2px; }
380
+ .card {
381
+ background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
382
+ border-radius: 16px; padding: 20px 22px;
383
+ }
384
+ .card-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #ffffff; margin-bottom: 10px; }
385
+ .url-box {
386
+ font-family: 'SF Mono','Fira Code','Consolas',monospace; font-size: 0.8rem;
387
+ color: #ffffff; background: rgba(0,0,0,0.25); border: 1px solid rgba(255,0,0,0.12);
388
+ border-radius: 10px; padding: 11px 14px; word-break: break-all; line-height: 1.5;
389
+ }
390
+ .actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
391
+ .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; }
392
+ .btn-install { background: #FF0000; color: #ffffff; }
393
+ .btn-install:hover { background: #cc0000; }
394
+ .btn-copy { background: #ffffff; color: #050810; border: 1px solid #ffffff; }
395
+ .btn-copy:hover { background: #e6e6e6; border-color: #e6e6e6; }
396
+ .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
397
+ .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; }
398
+ .stat-val { font-size: 1.5rem; font-weight: 800; color: #fff; line-height: 1; margin-bottom: 5px; }
399
+ .stat-lbl { font-size: 0.68rem; color: #aaa; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; white-space: pre-line; }
400
+ .made-by { text-align: center; color: #ffffff; font-size: 0.85rem; margin-top: 6px; opacity: 0.8; }
401
+ </style>
402
+ </head>
403
+ <body>
404
+ <div class="wrap">
405
+ <div class="header">
406
+ <div class="logo">
407
+ <span class="van">Van</span> <span class="streams">Streams+</span>
408
+ </div>
409
+ <div class="badge-row">
410
+ <span class="badge">🎬 Movies</span>
411
+ <span class="badge series">πŸ“Ί Series</span>
412
+ </div>
413
+ <p class="desc">Get Your 🍿 Ready</p>
414
+ </div>
415
+ <div class="card">
416
+ <div class="card-label">Stremio Install URL</div>
417
+ <div class="url-box">${manifestUrl}</div>
418
+ <div class="actions">
419
+ <a href="${installUrl}" class="btn btn-install">Install in Stremio</a>
420
+ <button class="btn btn-copy" onclick="navigator.clipboard.writeText('${manifestUrl}').then(()=>{this.textContent='βœ” Copied';setTimeout(()=>this.textContent='Copy URL',2000)})">Copy URL</button>
421
+ </div>
422
+ </div>
423
+ <div class="stats">
424
+ <div class="stat"><div class="stat-val">100K+</div><div class="stat-lbl">Movies</div></div>
425
+ <div class="stat"><div class="stat-val">40K+</div><div class="stat-lbl">Series</div></div>
426
+ <div class="stat"><div class="stat-val">20K+</div><div class="stat-lbl">Anime</div></div>
427
+ </div>
428
+ <div class="made-by">Made With 🀍 By Van</div>
429
+ </div>
430
+ </body>
431
+ </html>`);
432
+ });
433
+
434
+ app.listen(PORT, '0.0.0.0', () => console.log('VanStreams+ 🎬 v7.0.5 on :' + PORT));