vanseleem commited on
Commit
bc5d87d
·
verified ·
1 Parent(s): 44862d9

Update index.js

Browse files
Files changed (1) hide show
  1. index.js +167 -635
index.js CHANGED
@@ -1,670 +1,202 @@
1
- 'use strict';
2
-
3
  const express = require('express');
4
- const fetch = require('node-fetch');
5
- const AdmZip = require('adm-zip');
6
- const app = express();
7
- const PORT = process.env.PORT || 7860;
8
 
9
- const BASE_URL = process.env.SPACE_HOST
10
- ? `https://${process.env.SPACE_HOST}`
11
- : `http://localhost:${PORT}`;
12
 
13
  app.use((req, res, next) => {
14
- res.setHeader('Access-Control-Allow-Origin', '*');
15
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
16
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
17
  if (req.method === 'OPTIONS') return res.sendStatus(200);
18
  next();
19
  });
20
 
21
- const SUBDL_KEY = process.env.SUBDL_KEY || 'subdl_TsxunQv1rWjwCiXQqDC_lpBPF5QtwoFH2Vbae0zvKqI';
22
- const TMDB_KEY = process.env.TMDB_KEY || '83d364331c40bfbe29858aeed82f45cc';
23
-
24
- app.get('/', (req, res) => res.send('Van Subs v19 ✅ Online'));
25
-
26
- app.get('/manifest.json', (req, res) => res.json({
27
- id: 'org.van.humansubtitles',
28
- name: 'VanSubs+',
29
- description: 'Human-Verified Arabic & English Subtitles. Anti-AI. Format-first sync.',
30
- version: '19.0.0',
31
- resources: ['subtitles'],
32
- types: ['movie', 'series'],
33
- idPrefixes: ['tt'],
34
- catalogs: []
35
- }));
36
-
37
- app.get('/subtitles/:type/:id.json', (req, res) => handleRequest(req, res));
38
- app.get('/subtitles/:type/:id/:extra.json', (req, res) => handleRequest(req, res));
39
-
40
- app.get('/health', (req, res) => res.json({
41
- status: 'ok',
42
- version: '19.0.0',
43
- cache: cache.size,
44
- zips: subStore.size,
45
- uptime: Math.floor(process.uptime()) + 's',
46
- base_url: BASE_URL
47
- }));
48
-
49
- // ─── Cache ────────────────────────────────────────────────────────────────────
50
- const cache = new Map();
51
- const TTL_MOVIE = 7 * 24 * 60 * 60 * 1000;
52
- const TTL_EPISODE = 8 * 60 * 60 * 1000;
53
-
54
- function getTTL(type) { return type === 'series' ? TTL_EPISODE : TTL_MOVIE; }
55
- function cacheKey(imdbId, type, season, episode) {
56
- return `${imdbId}|${type}|${season ?? 'x'}|${episode ?? 'x'}`;
57
- }
58
-
59
- setInterval(() => {
60
- const now = Date.now(); let n = 0;
61
- for (const [k, v] of cache.entries()) { if (now - v.ts > v.ttl) { cache.delete(k); n++; } }
62
- if (n > 0) console.log(`[Cache] Evicted ${n} stale entries`);
63
- }, 15 * 60 * 1000);
64
-
65
- // ─── ZIP store ────────────────────────────────────────────────────────────────
66
- const subStore = new Map();
67
- let subSeq = 0;
68
 
69
- setInterval(() => {
70
- if (subStore.size > 500) {
71
- const keys = [...subStore.keys()].slice(0, 250);
72
- keys.forEach(k => subStore.delete(k));
73
- console.log('[ZipStore] Flushed 250 old entries');
74
- }
75
- }, 30 * 60 * 1000);
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
- // ---------------------------------------------------------------------------
78
- // Extract episode number from a filename (SxxExx → number, else null)
79
- // ---------------------------------------------------------------------------
80
- function episodeFromFilename(filename) {
81
- if (!filename) return null;
82
- const n = filename.toLowerCase();
83
- const m1 = n.match(/s\d{1,2}e(\d{1,3})/);
84
- if (m1) return parseInt(m1[1], 10);
85
- const m2 = n.match(/[._\-\s]e(\d{1,3})[._\-\s]/);
86
- if (m2) return parseInt(m2[1], 10);
 
 
 
 
 
 
 
 
 
 
 
87
  return null;
88
  }
89
 
90
- // ---------------------------------------------------------------------------
91
- // Pick best file from ZIP — prefers exact episode match for series
92
- // ---------------------------------------------------------------------------
93
- function pickFileFromZip(zip, lang, season, episode) {
94
- const entries = zip.getEntries()
95
- .filter(e => !e.isDirectory)
96
- .filter(e => /\.(srt|ass|ssa|vtt)$/i.test(e.entryName));
97
- if (!entries.length) return null;
98
-
99
- if (season != null && episode != null) {
100
- const target = `s${String(season).padStart(2,'0')}e${String(episode).padStart(2,'0')}`;
101
- const exact = entries.find(e => e.entryName.toLowerCase().includes(target));
102
- if (exact) return exact;
103
- const loose = `s${season}e${episode}`;
104
- if (loose !== target) {
105
- const looseMatch = entries.find(e => e.entryName.toLowerCase().includes(loose));
106
- if (looseMatch) return looseMatch;
107
- }
108
- }
109
-
110
- const hints = lang === 'Arabic'
111
- ? ['arabic', 'arab', '.ar.', '_ar_', '.ara.', '_ara_', 'ar.srt', 'ar.ass']
112
- : ['english', 'eng', '.en.', '_en_', '.eng.', '_eng_', 'en.srt', 'en.ass'];
113
-
114
- const hinted = entries.find(e => hints.some(h => e.entryName.toLowerCase().includes(h)));
115
- if (hinted) return hinted;
116
- if (entries.length === 1) return entries[0];
117
-
118
- return [...entries].sort((a, b) =>
119
- lang === 'Arabic' ? a.header.size - b.header.size : b.header.size - a.header.size
120
- )[0];
121
  }
122
 
123
- // ---------------------------------------------------------------------------
124
- // Resolve a subdl URL (direct or ZIP). Retries once on AbortError.
125
- // Timeout raised to 12 s.
126
- // ---------------------------------------------------------------------------
127
- async function resolveUrl(rawUrl, lang, season, episode) {
128
- if (/\.(srt|ass|ssa|vtt)(\?|$)/i.test(rawUrl)) {
129
- return { url: rawUrl, extractedName: null };
130
- }
131
- for (let attempt = 1; attempt <= 2; attempt++) {
132
- try {
133
- const r = await timedFetch(rawUrl, {}, 12000);
134
- if (!r.ok) { console.error(`[ZIP] HTTP ${r.status}`); return null; }
135
- const zip = new AdmZip(await r.buffer());
136
- const best = pickFileFromZip(zip, lang, season, episode);
137
- if (!best) return null;
138
- const ext = best.entryName.split('.').pop().toLowerCase();
139
- const id = `s${++subSeq}`;
140
- subStore.set(id, { buffer: best.getData(), ext });
141
- const publicUrl = `${BASE_URL}/sub/${id}.${ext}`;
142
- console.log(`[ZIP] "${best.entryName}" (${best.header.size}B) lang=${lang} → /sub/${id}`);
143
- return { url: publicUrl, extractedName: best.entryName };
144
- } catch (e) {
145
- if (attempt < 2 && e.name === 'AbortError') {
146
- console.log(`[ZIP] AbortError attempt ${attempt}, retrying in 500ms...`);
147
- await new Promise(res => setTimeout(res, 500));
148
- } else {
149
- console.error(`[ZIP] error (attempt ${attempt}):`, e.message);
150
- return null;
151
- }
152
- }
153
- }
154
  return null;
155
  }
156
 
157
- // ---------------------------------------------------------------------------
158
- // Concurrency-limited pool resolver — max `limit` parallel ZIP downloads.
159
- // Prevents the cascade of AbortErrors when many ZIPs fire simultaneously.
160
- // ---------------------------------------------------------------------------
161
- async function resolvePool(pool, limit, season, episode) {
162
- const results = new Array(pool.length).fill(null);
163
- let idx = 0;
164
- async function worker() {
165
- while (true) {
166
- const i = idx++;
167
- if (i >= pool.length) break;
168
- const s = pool[i];
169
- const result = await resolveUrl(s.url, s.lang, season, episode);
170
- if (result) {
171
- results[i] = { ...s, url: result.url, filename: result.extractedName || s.filename };
172
- }
173
- }
174
  }
175
- await Promise.all(Array.from({ length: Math.min(limit, pool.length) }, () => worker()));
176
- return results.filter(Boolean);
177
- }
178
-
179
- // ─── Subtitle serving ─────────────────────────────────────────────────────────
180
- app.get('/sub/:id', (req, res) => {
181
- const id = req.params.id.replace(/\.[^.]+$/, '');
182
- const item = subStore.get(id);
183
- if (!item) return res.status(404).send('Not found');
184
- const mime = item.ext === 'vtt' ? 'text/vtt' : 'text/plain';
185
- res.setHeader('Content-Type', `${mime}; charset=utf-8`);
186
- res.setHeader('Content-Disposition', `inline; filename="sub.${item.ext}"`);
187
- res.send(item.buffer);
188
- });
189
-
190
- // ─── Language resolution ──────────────────────────────────────────────────────
191
- const ARABIC_CODES = new Set(['ar', 'ara', 'arabic']);
192
- const ENGLISH_CODES = new Set(['en', 'eng', 'english']);
193
-
194
- function resolveLanguage(code) {
195
- const c = (code || '').toLowerCase().trim();
196
- if (ARABIC_CODES.has(c)) return 'Arabic';
197
- if (ENGLISH_CODES.has(c)) return 'English';
198
  return null;
199
  }
200
 
201
- // ─── Filters ──────────────────────────────────────────────────────────────────
202
- const FOREIGN_SIGNALS = [
203
- 'spanish','español','espanol','latino','castellano','.es.','_es_','.spa.','_spa_','.lat.','_lat_','[lat]','(lat)',
204
- 'french','français','francais','.fr.','_fr_','.fre.','_fre_',
205
- 'portuguese','português','brazil','brasil','.pt.','_pt.','.por.','_por.','.br.','_br_',
206
- 'german','deutsch','.de.','_de_','.ger.','_ger_',
207
- 'italian','italiano','.it.','_it_','.ita.','_ita_',
208
- 'dutch','nederlands','.nl.','_nl.','.dut.','_dut_','[nl]',
209
- 'turkish','türkçe','.tr.','_tr_','.tur.','_tur_',
210
- 'russian','.ru.','_ru_','.rus.','_rus_',
211
- 'chinese','.zh.','_zh_','korean','.ko.','_ko_','hindi','.hi.','_hi_',
212
- ];
213
-
214
- function isForeignFilename(filename, lang) {
215
- if (lang === 'Arabic') return false;
216
- const n = (filename || '').toLowerCase();
217
- return FOREIGN_SIGNALS.some(s => n.includes(s));
218
- }
219
-
220
- const AI_GARBAGE = [
221
- 'ai-translated','machine translation','chatgpt','translated from','auto-translated',
222
- 'google translate','deepl','whisper','ai generated','machine-translated','openai',
223
- 'gpt-4','gpt4','auto translate','ai.translated','aitranslated','autotranslated',
224
- 'machine.translation','subscene-ai','opensubtitles-bot','mtranslated','mt.translated',
225
- 'ai_translated','translated.by.ai','translated_by_ai',
226
- ];
227
-
228
- function isHumanVerified(filename) {
229
- const n = (filename || '').toLowerCase();
230
- return !AI_GARBAGE.some(f => n.includes(f));
231
- }
232
-
233
- // Filters forced tracks, commentary, audio-description.
234
- // These are NOT regular subtitle tracks and should never appear in results.
235
- const USELESS_SIGNALS = [
236
- '.forced.', '.forced_', '_forced.', '[forced]', '(forced)',
237
- 'en.forced', 'ar.forced', 'fr.forced', 'forced.srt', 'forced.ass',
238
- 'commentary',
239
- 'audio.description', 'audio description',
240
- ];
241
-
242
- function isUsableSubtitle(filename) {
243
- const n = (filename || '').toLowerCase();
244
- return !USELESS_SIGNALS.some(f => n.includes(f));
245
- }
246
-
247
- // ─── Scoring tables ───────────────────────────────────────────────────────────
248
-
249
- // Release groups — low weight signal
250
- const SCENE_GROUPS = [
251
- 'qxr','psa','galaxyrg','ntb','deflate','epsilon','tigole','framestor',
252
- 'yts','yify','rarbg','fgt','amzn','flux','cm','tepes','heve','evo','ion10','glhf'
253
- ];
254
-
255
- // Source format tiers for tier-match / tier-mismatch logic
256
- const SOURCE_TIERS = [
257
- { tags: ['bluray','blu-ray','bdrip','bdremux','remux'], tier: 'bluray', pts: 80 },
258
- { tags: ['webdl','web-dl','webrip','web-rip'], tier: 'web', pts: 70 },
259
- { tags: ['hdtv','amzn','atvp','dsnp','nf','hulu'], tier: 'hdtv', pts: 50 },
260
- { tags: ['dvdrip','hdrip','dvd'], tier: 'dvd', pts: 25 },
261
- ];
262
-
263
- const CODEC_TAGS = ['x265','x264','hevc','h265','h264','10bit','10-bit','hdr','hdr10','dv','dolbyvision','2160p','4k','uhd','1080p','1080','720p'];
264
- const AUDIO_TAGS = ['truehd.atmos','truehd','atmos','ddp5.1','ddp5','ddp','dts-hd','dts.hd','dts','ac3','aac','eac3'];
265
-
266
- // NOTE: 'remux' removed from SYNC_BOOST — it describes video container quality,
267
- // not subtitle sync quality. Its presence was unfairly boosting BluRay Remux subs.
268
- const SYNC_BOOST = ['sync','synced','corrected','fixed','updated','proper','repack','retail','bd.sync','bluray.sync'];
269
- const SYNC_PENALTY = ['unsynced','unsync','rough','workprint','not.synced','not_synced','notsynced','raw.sub','raw_sub'];
270
-
271
- function normalizeRelease(name) {
272
- if (!name) return [];
273
- return name.toLowerCase()
274
- .replace(/\[.*?\]/g, ' ').replace(/\(.*?\)/g, ' ')
275
- .replace(/[^a-z0-9]+/g, ' ').trim().split(/\s+/).filter(t => t.length > 1);
276
- }
277
-
278
- function detectTier(text) {
279
- if (!text) return null;
280
- const n = text.toLowerCase();
281
- for (const entry of SOURCE_TIERS) {
282
- if (entry.tags.some(t => n.includes(t))) return { tier: entry.tier, pts: entry.pts };
283
- }
284
- return null;
285
  }
286
 
287
- // ---------------------------------------------------------------------------
288
- // scoreTrack — V19
289
- //
290
- // SubDL does NOT return a `rating` field so ratings are permanently 0.
291
- // All scoring is based on filename signals.
292
- //
293
- // Scoring priority (highest → lowest):
294
- // 1. Episode exact match +500 hard requirement for series
295
- // 2. Downloads popularity 0–60 if SubDL returns the field
296
- // 3. Source format tier match/mismatch vs stream hint
297
- // hasHint + same tier +pts×2
298
- // hasHint + different tier -200 overwhelms codec tag bias
299
- // no hint + web tier +pts+40
300
- // no hint + other tier +pts
301
- // 4. Codec / resolution tags small additive (mult halved when no hint)
302
- // 5. Audio tags small additive
303
- // 6. Sync quality signals ±30 / ±60
304
- // 7. Release group very low weight
305
- // 8. No-hint popularity boosts:
306
- // YTS/YIFY +100 dominant torrent source
307
- // RARBG +40
308
- // WEBRip +20
309
- // 1080p +40 most common stream resolution
310
- // UHD/4K/2160p -80 rarely syncs with 1080p streams
311
- // Streaming services +25 WAVO/OSN/Shahid/AMZN/NFLX/HMAX
312
- // MAX/HBO Max (word-safe) +25
313
- // 9. SDH -30 mild penalty
314
- // ---------------------------------------------------------------------------
315
- function scoreTrack(subFilename, streamTitle, rating = 0, season = null, episode = null, downloads = 0) {
316
- if (!subFilename) return 0;
317
- const n = subFilename.toLowerCase();
318
- const subTokens = normalizeRelease(subFilename);
319
- const strmTokens = normalizeRelease(streamTitle || '');
320
-
321
- // hasHint = true only if the stream title contains a recognisable quality tier.
322
- // Empty titles and plain episode filenames ("Shingeki.S02E09.mkv") have no tier.
323
- const strmTier = detectTier(streamTitle || '');
324
- const hasHint = !!strmTier;
325
-
326
- let s = 0;
327
-
328
- // Hard-drop safety net (belt-and-suspenders; filter already removes these)
329
- if (n.includes('.forced.') || n.includes('commentary')) return -9999;
330
-
331
- // 1. Episode exact match
332
- if (season != null && episode != null) {
333
- const pat = `s${String(season).padStart(2,'0')}e${String(episode).padStart(2,'0')}`;
334
- if (n.includes(pat)) s += 500;
335
- else {
336
- const loose = `s${season}e${episode}`;
337
- if (loose !== pat && n.includes(loose)) s += 500;
338
- }
339
- }
340
-
341
- // 2. Rating (SubDL currently returns 0, kept for future API changes)
342
- if (rating > 0) s += Math.round(rating * 18);
343
-
344
- // 2b. Downloads — popularity signal capped at +60 to avoid dominance
345
- // Formula: min(downloads, 1000) × 0.06 → max 60pts at 1000 downloads
346
- if (downloads > 0) s += Math.min(Math.round(downloads * 0.06), 60);
347
-
348
- // 3. Source format tier — match/mismatch vs stream hint
349
- const subTier = detectTier(n);
350
- if (subTier) {
351
- if (hasHint) {
352
- // We know stream quality: reward exact match, penalise mismatch.
353
- // Penalty -200 is large enough to overcome codec tag accumulation.
354
- if (subTier.tier === strmTier.tier) s += subTier.pts * 2;
355
- else s -= 200;
356
- } else {
357
- // No stream quality hint: WEB is the safest universal default.
358
- if (subTier.tier === 'web') s += subTier.pts + 40;
359
- else s += subTier.pts;
360
- }
361
- }
362
-
363
- // 4. Codec / resolution tags
364
- // No-hint multiplier is 2 (was 6) — prevents UHD titles monopolising via tag count.
365
- const codecMult = hasHint ? 6 : 2;
366
- CODEC_TAGS.forEach((t, i) => { if (n.includes(t)) s += (CODEC_TAGS.length - i) * codecMult; });
367
-
368
- // 5. Audio tags
369
- const audioMult = hasHint ? 5 : 2;
370
- AUDIO_TAGS.forEach((t, i) => { if (n.includes(t)) s += (AUDIO_TAGS.length - i) * audioMult; });
371
-
372
- // 6. Sync quality signals
373
- SYNC_BOOST.forEach( b => { if (n.includes(b)) s += 30; });
374
- SYNC_PENALTY.forEach(p => { if (n.includes(p)) s -= 60; });
375
-
376
- // 7. Release group — lowest weight
377
- for (const g of SCENE_GROUPS) {
378
- const inSub = subTokens.includes(g);
379
- const inStrm = strmTokens.includes(g);
380
- if (inSub && inStrm) s += 15;
381
- else if (inSub) s += 5;
382
- }
383
-
384
- // 8. No-hint popularity & resolution adjustments
385
- // Only applied when we cannot detect what the user is actually streaming.
386
- if (!hasHint) {
387
- // YTS/YIFY: the single most common torrent stream source — most universally compatible
388
- if (['yts', 'yify'].some(g => subTokens.includes(g))) {
389
- s += 100;
390
- } else if (subTokens.includes('rarbg')) {
391
- s += 40;
392
- }
393
-
394
- // WEBRip: very common stream format, extra nudge on top of web tier
395
- if (n.includes('webrip') || n.includes('web-rip')) s += 20;
396
-
397
- // 1080p: most common streaming resolution — preferred over 4K or 720p
398
- if (n.includes('1080p')) s += 40;
399
-
400
- // UHD/4K penalty: a 4K subtitle will almost never sync with a 1080p stream.
401
- // When there is no hint we must assume the common case (1080p WEB/YTS).
402
- if (n.includes('2160p') ||
403
- /[.\s\-_]uhd[.\s\-_]/.test(n) ||
404
- /[.\s\-_]4k[.\s\-_]/.test(n)) {
405
- s -= 80;
406
- }
407
-
408
- // Streaming-service subs (WAVO, OSN, Shahid, AMZN, NF, HMAX) tend to sync
409
- // well against whatever version most people stream online.
410
- const STREAM_SVCS = ['wavo', 'osn', 'shahid', 'amzn', 'nflx', 'hmax', 'amazon'];
411
- if (STREAM_SVCS.some(sv => n.includes(sv))) s += 25;
412
-
413
- // MAX / HBO Max — word-boundary safe (avoids matching "maximum", "climax", etc.)
414
- if (/[.\-_]max[.\-_]/.test(n) || n.endsWith('.max')) s += 25;
415
  }
416
-
417
- // 9. SDH mild penalty — sound-effect descriptions are noise for most viewers
418
- if (n.includes('sdh') || n.includes('hearing.impaired')) s -= 30;
419
-
420
- return s;
421
  }
422
 
423
- async function timedFetch(url, opts = {}, ms = 6000) {
424
- const ctrl = new AbortController();
425
- const timer = setTimeout(() => ctrl.abort(), ms);
426
- try { return await fetch(url, { ...opts, signal: ctrl.signal }); }
427
- finally { clearTimeout(timer); }
428
- }
429
-
430
- async function safeJson(r, tag) {
431
- const text = await r.text();
432
- try { return JSON.parse(text); }
433
- catch { console.error(`[${tag}] bad JSON:`, text.slice(0, 200)); return null; }
434
- }
435
-
436
- async function handleRequest(req, res) {
437
- const { type } = req.params;
438
- const rawId = req.params.id.replace('.json', '');
439
- const extra = req.params.extra ? decodeURIComponent(req.params.extra.replace('.json','')) : '';
440
- const parts = rawId.split(':');
441
- const imdbId = parts[0];
442
- const season = parts[1] ? parseInt(parts[1]) : null;
443
- const episode = parts[2] ? parseInt(parts[2]) : null;
444
-
445
- const filenameMatch = extra.match(/filename=([^&]+)/i);
446
- const streamTitle = filenameMatch ? decodeURIComponent(filenameMatch[1]) : '';
447
-
448
- const hintTier = detectTier(streamTitle);
449
-
450
- console.log(`\n╔═ [VanSubs v19] ${type.toUpperCase()} ${imdbId}` +
451
- (season !== null ? ` S${String(season).padStart(2,'0')}E${String(episode??0).padStart(2,'0')}` : '') +
452
- (streamTitle ? `\n║ hint: "${streamTitle}"` : '') +
453
- (hintTier ? ` [${hintTier.tier.toUpperCase()}]` : streamTitle ? ' [no-tier]' : ' [no-hint]'));
454
-
455
- const ck = cacheKey(imdbId, type, season, episode);
456
- const hit = cache.get(ck);
457
- if (hit && (Date.now() - hit.ts) < hit.ttl) {
458
- console.log(`╚═ ✅ Cache hit — ${hit.subtitles.length} tracks`);
459
- return res.json({ subtitles: hit.subtitles });
460
- }
461
-
462
- let rawTracks = [];
463
- try { rawTracks = await fetchSubDL(imdbId, type, season, episode); }
464
- catch (e) { console.error('║ [SubDL] FAILED:', e.message); }
465
- console.log(`║ [SubDL] ${rawTracks.length} raw tracks`);
466
-
467
- const seen = new Set();
468
- const deduped = rawTracks.filter(s => {
469
- const key = (s.filename || '').toLowerCase().replace(/[\s.\-_]+/g, '.').replace(/\.(srt|ass|zip)$/, '');
470
- if (seen.has(key)) return false;
471
- seen.add(key); return true;
472
  });
473
-
474
- const clean = deduped.filter(s => {
475
- if (!s.url) return false;
476
- if (!isUsableSubtitle(s.filename)) { console.log(`║ [FORCED-DROP] "${s.filename}"`); return false; }
477
- if (!isHumanVerified(s.filename)) { console.log(`║ [AI-DROP] "${s.filename}"`); return false; }
478
- if (isForeignFilename(s.filename, s.lang)) { console.log(`║ [FOREIGN-DROP] "${s.filename}"`); return false; }
479
- return true;
 
 
 
 
 
 
 
 
 
 
480
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
 
482
- // Phase 1 — pre-sort on release_name with V19 scoring
483
- const scored = clean
484
- .map(s => ({ ...s, score: scoreTrack(s.filename, streamTitle, s.rating, null, null, s.downloads) }))
485
- .sort((a, b) => b.score - a.score);
486
-
487
- // Take top 8 per language (up from 6) — wider candidate pool for ZIP extraction.
488
- // The real filename inside a ZIP often has better tags than the SubDL release_name,
489
- // so Phase 3 re-score can reorder them properly.
490
- const arabic = scored.filter(s => s.lang === 'Arabic').slice(0, 8);
491
- const english = scored.filter(s => s.lang === 'English').slice(0, 8);
492
- const pool = [...arabic, ...english];
493
-
494
- // Phase 2 — resolve URLs with concurrency limit (4 parallel max)
495
- const resolved = await resolvePool(pool, 4, season, episode);
496
-
497
- // Phase 3 — re-score on actual extracted filename + episode boost
498
- const rescored = resolved
499
- .map(s => ({
500
- ...s,
501
- finalScore: scoreTrack(s.filename, streamTitle, s.rating, season, episode, s.downloads)
502
- }))
503
- .sort((a, b) => b.finalScore - a.finalScore);
504
-
505
- // Trim to top 4 per language after the decisive re-score
506
- const arFinal = rescored.filter(s => s.lang === 'Arabic').slice(0, 4);
507
- const enFinal = rescored.filter(s => s.lang === 'English').slice(0, 4);
508
- const finalSorted = [...arFinal, ...enFinal];
509
-
510
- const subtitles = finalSorted.map((s, i) => ({
511
- id: `van_${s.lang === 'Arabic' ? 'ar' : 'en'}_${imdbId}_${i}`,
512
- url: s.url,
513
- lang: s.lang
514
- }));
515
-
516
- cache.set(ck, { subtitles, ts: Date.now(), ttl: getTTL(type) });
517
-
518
- const ar = finalSorted.filter(s => s.lang === 'Arabic');
519
- const en = finalSorted.filter(s => s.lang === 'English');
520
- console.log(`║ ✅ ${subtitles.length} tracks (AR:${ar.length} EN:${en.length})`);
521
- ar.forEach((s, i) => console.log(`║ AR[${i}] ${String(s.finalScore).padStart(4)}pt dl=${s.downloads} "${s.filename}"`));
522
- en.forEach((s, i) => console.log(`║ EN[${i}] ${String(s.finalScore).padStart(4)}pt dl=${s.downloads} "${s.filename}"`));
523
- console.log(`╚═ done`);
524
-
525
- res.json({ subtitles });
526
- }
527
-
528
- // ─── SubDL API helpers ────────────────────────────────────────────────────────
529
- async function subDLRequestWithFallback(baseParams) {
530
- let data = await subDLRequest(baseParams + '&type=tv');
531
- if (data && data.length > 0) return data;
532
- return await subDLRequest(baseParams);
533
- }
534
-
535
- async function subDLRequest(params) {
536
- // subs_per_page raised to 50 (was 30) — wider candidate pool
537
- const base = `https://api.subdl.com/api/v1/subtitles?api_key=${SUBDL_KEY}&languages=AR,EN&subs_per_page=50&releases=1`;
538
- const url = base + params;
539
- const r = await timedFetch(url, {}, 6000);
540
- const data = await safeJson(r, 'SubDL');
541
- if (!data?.status) { console.error('║ [SubDL] error:', data?.error); return []; }
542
- return data.subtitles || [];
543
- }
544
-
545
- function parseSubDLResults(subs) {
546
- // One-time field audit — helps diagnose future SubDL API changes.
547
- // Will print once per request batch the first time results arrive.
548
- if (subs.length > 0) {
549
- const keys = Object.keys(subs[0]);
550
- const hasRating = keys.includes('rating') || keys.includes('rate');
551
- const hasDl = keys.includes('downloads') || keys.includes('download_count') || keys.includes('hits');
552
- if (!hasRating) console.log(`║ [SubDL-fields] no rating field. available: ${keys.join(', ')}`);
553
- if (hasDl) console.log(`║ [SubDL-fields] download count field found`);
554
- }
555
-
556
- return subs.map(sub => {
557
- const lang = resolveLanguage(sub.language);
558
- if (!lang) return null;
559
- let dlUrl = (sub.url || '').trim();
560
- if (dlUrl && !dlUrl.startsWith('http')) dlUrl = 'https://dl.subdl.com' + dlUrl;
561
- if (!dlUrl) return null;
562
- return {
563
- filename: sub.release_name || sub.full_name || sub.name || 'SubDL Track',
564
- url: dlUrl,
565
- lang,
566
- rating: parseFloat(sub.rating || sub.rate || 0) || 0,
567
- downloads: parseInt(sub.downloads || sub.download_count || sub.hits || 0, 10) || 0,
568
- source: 'subdl',
569
- season: sub.season ?? null,
570
- episode: sub.episode ?? null,
571
- episodeFrom: sub.episode_from ?? null,
572
- episodeTo: sub.episode_end ?? null,
573
- fullSeason: sub.full_season ?? false,
574
- };
575
- }).filter(Boolean);
576
- }
577
-
578
- function episodeFilter(s, targetSeason, targetEp, callSite) {
579
- const seasonOk = !s.season || s.season === 0 || s.season === targetSeason;
580
- if (!seasonOk) {
581
- console.log(`║ [EP-FILTER] dropped wrong-season from ${callSite}: "${s.filename}"`);
582
- return false;
583
- }
584
-
585
- if (s.episodeFrom > 0 && s.episodeTo > 0) {
586
- const inRange = targetEp >= s.episodeFrom && targetEp <= s.episodeTo;
587
- if (!inRange) console.log(`║ [EP-FILTER] dropped out-of-range from ${callSite}: "${s.filename}"`);
588
- return inRange;
589
- }
590
-
591
- if (s.fullSeason) {
592
- const fnEp = episodeFromFilename(s.filename);
593
- if (fnEp !== null && fnEp !== targetEp) {
594
- console.log(`║ [EP-FILTER] dropped wrong-episode (fullSeason, filename) from ${callSite}: "${s.filename}"`);
595
- return false;
596
- }
597
- return true;
598
- }
599
-
600
- if (s.episode !== null && s.episode !== 0) {
601
- const match = s.episode === targetEp;
602
- if (!match) console.log(`║ [EP-FILTER] dropped wrong-episode from ${callSite}: "${s.filename}"`);
603
- return match;
604
- }
605
-
606
- const fnEp = episodeFromFilename(s.filename);
607
- if (fnEp !== null && fnEp !== targetEp) {
608
- console.log(`║ [EP-FILTER] dropped wrong-episode (filename) from ${callSite}: "${s.filename}"`);
609
- return false;
610
  }
 
611
 
612
- return true;
613
- }
614
-
615
- async function tmdbLookup(imdbId) {
616
  try {
617
- const r = await timedFetch(
618
- `https://api.themoviedb.org/3/find/${imdbId}?api_key=${TMDB_KEY}&external_source=imdb_id&language=en`,
619
- {}, 6000
620
- );
621
- if (!r || !r.ok) return null;
622
- const data = await r.json();
623
- const movie = data.movie_results?.[0];
624
- const tv = data.tv_results?.[0];
625
- if (movie) return { title: movie.title || movie.original_title, year: (movie.release_date || '').slice(0, 4) };
626
- if (tv) return { title: tv.name || tv.original_name, year: (tv.first_air_date || '').slice(0, 4) };
627
- return null;
628
- } catch (e) { console.error('[TMDB]', e.message); return null; }
629
- }
630
-
631
- async function fetchSubDL(imdbId, type, season, episode) {
632
- if (type !== 'series' || season === null || episode === null) {
633
- let subs = await subDLRequest(`&imdb_id=${imdbId}`);
634
- if (subs.length === 0) {
635
- const meta = await tmdbLookup(imdbId);
636
- if (meta && meta.title) {
637
- console.log(`║ [SubDL] IMDb failed, trying film_name="${meta.title}"...`);
638
- subs = await subDLRequest(`&film_name=${encodeURIComponent(meta.title)}&type=movie`);
639
- }
640
- }
641
- return parseSubDLResults(subs);
642
  }
 
643
 
644
- const [episodeSubs, seasonSubs] = await Promise.all([
645
- subDLRequestWithFallback(`&imdb_id=${imdbId}&season_number=${season}&episode_number=${episode}`),
646
- subDLRequestWithFallback(`&imdb_id=${imdbId}&season_number=${season}&full_season=1`)
647
- ]);
648
-
649
- const episodeParsed = parseSubDLResults(episodeSubs)
650
- .filter(s => episodeFilter(s, season, episode, 'episodeCall'));
651
-
652
- const seasonParsed = parseSubDLResults(seasonSubs)
653
- .filter(s => episodeFilter(s, season, episode, 'seasonCall'));
654
-
655
- console.log(`║ [SubDL] episode=${episodeParsed.length} season_pack=${seasonParsed.length}`);
656
-
657
- const combined = [...episodeParsed, ...seasonParsed];
658
- const seen = new Set();
659
- return combined.filter(s => {
660
- const key = (s.filename || '').toLowerCase().replace(/[\s.\-_]+/g, '.');
661
- if (seen.has(key)) return false;
662
- seen.add(key); return true;
663
- });
664
- }
665
-
666
- app.listen(PORT, '0.0.0.0', () => {
667
- console.log(`\nVan Subs v19 — port ${PORT}`);
668
- console.log(`BASE_URL: ${BASE_URL}`);
669
- console.log(`SPACE_HOST env: ${process.env.SPACE_HOST || '(not set — using localhost)'}\n`);
670
- });
 
 
 
1
  const express = require('express');
2
+ const fetch = require('node-fetch');
 
 
 
3
 
4
+ const app = express();
5
+ const PORT = process.env.PORT || 7860;
 
6
 
7
  app.use((req, res, next) => {
8
+ res.header('Access-Control-Allow-Origin', '*');
9
+ res.header('Access-Control-Allow-Headers', '*');
10
+ res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
11
  if (req.method === 'OPTIONS') return res.sendStatus(200);
12
  next();
13
  });
14
 
15
+ const UPSTREAM_URLS = [
16
+ 'https://torrentio.strem.fun',
17
+ 'https://torrentio.stremio.my.id'
18
+ ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
+ const MIN_SEEDERS = 1;
21
+ const ALLOWED_QUALITIES = ['480p', '576p', '720p', '1080p', 'webrip', 'webdl', 'bluray'];
22
+ const MAX_SIZE_GB_MOVIE = 4;
23
+ const MAX_SIZE_GB_SERIES = 1.5;
24
+
25
+ const SOURCE_CONFIG = [
26
+ { name: 'yts', display: 'YTS', order: 1 },
27
+ { name: 'yify', display: 'YTS', order: 1 },
28
+ { name: 'eztv', display: 'EZTV', order: 2 },
29
+ { name: 'nyaasi', display: 'Nyaa', order: 3 },
30
+ { name: 'thepiratebay', display: 'TPB', order: 4 },
31
+ { name: 'piratebay', display: 'TPB', order: 4 },
32
+ { name: 'the piratebay', display: 'TPB', order: 4 },
33
+ { name: 'tpb', display: 'TPB', order: 4 },
34
+ { name: '1337x', display: '1337x', order: 5 },
35
+ { name: 'torrentgalaxy', display: 'TGx', order: 6 },
36
+ { name: 'rarbg', display: 'RARBG', order: 7 },
37
+ { name: 'rutor', display: 'Rutor', order: 8 },
38
+ { name: 'rutracker', display: 'Rutracker', order: 9 }
39
+ ];
40
 
41
+ const QUALITY_RANK = {
42
+ '1080p': 1,
43
+ '720p': 2,
44
+ 'webdl': 3,
45
+ 'webrip': 4,
46
+ '576p': 5,
47
+ '480p': 6,
48
+ 'bluray': 7
49
+ };
50
+
51
+ const LINKS_PER_QUALITY = 5;
52
+
53
+ function getQuality(title = '') {
54
+ const lower = title.toLowerCase();
55
+ if (lower.includes('1080p')) return '1080p';
56
+ if (lower.includes('720p')) return '720p';
57
+ if (lower.includes('480p')) return '480p';
58
+ if (lower.includes('576p')) return '576p';
59
+ if (lower.includes('webrip')) return 'webrip';
60
+ if (lower.includes('webdl') || lower.includes('web-dl')) return 'webdl';
61
+ if (lower.includes('bluray')) return 'bluray';
62
  return null;
63
  }
64
 
65
+ function getSeeders(stream) {
66
+ if (stream.behaviorHints?.seeders) return stream.behaviorHints.seeders;
67
+ const match = stream.title?.match(/👤\s*(\d+)/);
68
+ return match ? parseInt(match[1], 10) : 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  }
70
 
71
+ function getSizeInGB(stream) {
72
+ const title = stream.title || '';
73
+ const sizeMatch = title.match(/💾\s*([\d.]+)\s*(GB|MB)/i) ||
74
+ title.match(/([\d.]+)\s*(GB|MB)/i);
75
+ if (!sizeMatch) return null;
76
+
77
+ const value = parseFloat(sizeMatch[1]);
78
+ const unit = sizeMatch[2].toUpperCase();
79
+
80
+ if (unit === 'GB') return value;
81
+ if (unit === 'MB') return value / 1024;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  return null;
83
  }
84
 
85
+ function detectSource(title = '') {
86
+ const lower = title.toLowerCase();
87
+ for (const src of SOURCE_CONFIG) {
88
+ if (lower.includes(src.name)) return src.name;
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  return null;
91
  }
92
 
93
+ function getSourceOrder(sourceName) {
94
+ const config = SOURCE_CONFIG.find(c => c.name === sourceName);
95
+ return config ? config.order : 999;
96
+ }
97
+
98
+ function filterStream(stream, type = 'movie') {
99
+ const title = stream.title || '';
100
+ if (!stream.url && !stream.infoHash) return false;
101
+
102
+ const quality = getQuality(title);
103
+ if (!ALLOWED_QUALITIES.includes(quality)) return false;
104
+
105
+ if (getSeeders(stream) < MIN_SEEDERS) return false;
106
+
107
+ if (!detectSource(title)) return false;
108
+
109
+ const maxSize = type === 'series' ? MAX_SIZE_GB_SERIES : MAX_SIZE_GB_MOVIE;
110
+ const sizeGB = getSizeInGB(stream);
111
+ if (sizeGB !== null && sizeGB > maxSize) return false;
112
+
113
+ return true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  }
115
 
116
+ async function fetchUpstream(type, id) {
117
+ for (const baseUrl of UPSTREAM_URLS) {
118
+ try {
119
+ const controller = new AbortController();
120
+ const timeout = setTimeout(() => controller.abort(), 8000);
121
+ const res = await fetch(`${baseUrl}/stream/${type}/${id}.json`, { signal: controller.signal });
122
+ clearTimeout(timeout);
123
+ if (res.ok) {
124
+ const data = await res.json();
125
+ if (data.streams?.length) return data.streams;
126
+ }
127
+ } catch (e) {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  }
129
+ return [];
 
 
 
 
130
  }
131
 
132
+ function sortAndGroup(streams, type = 'movie') {
133
+ const filtered = streams.filter(s => filterStream(s, type));
134
+
135
+ const byQuality = {};
136
+ filtered.forEach(stream => {
137
+ const quality = getQuality(stream.title);
138
+ if (!byQuality[quality]) byQuality[quality] = [];
139
+ byQuality[quality].push(stream);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  });
141
+
142
+ for (const quality in byQuality) {
143
+ byQuality[quality].sort((a, b) => {
144
+ const sourceA = detectSource(a.title);
145
+ const sourceB = detectSource(b.title);
146
+ const orderA = getSourceOrder(sourceA);
147
+ const orderB = getSourceOrder(sourceB);
148
+ if (orderA !== orderB) return orderA - orderB;
149
+ return getSeeders(b) - getSeeders(a);
150
+ });
151
+ }
152
+
153
+ const result = [];
154
+ const sortedQualities = Object.keys(byQuality).sort((a, b) => {
155
+ const rankA = QUALITY_RANK[a] || 999;
156
+ const rankB = QUALITY_RANK[b] || 999;
157
+ return rankA - rankB;
158
  });
159
+
160
+ sortedQualities.forEach(quality => {
161
+ const topLinks = byQuality[quality].slice(0, LINKS_PER_QUALITY);
162
+ result.push(...topLinks);
163
+ });
164
+
165
+ return result;
166
+ }
167
+
168
+ app.get('/manifest.json', (req, res) => {
169
+ res.json({
170
+ id: 'org.ghostream.platinum',
171
+ name: 'Ghostream 🚀',
172
+ description: 'Stream Everything You Want\nMade With 🤍 By Van',
173
+ version: '7.3.0',
174
+ resources: ['stream'],
175
+ types: ['movie', 'series'],
176
+ idPrefixes: ['tt'],
177
+ catalogs: []
178
+ });
179
+ });
180
 
181
+ app.get('/stream/:type/:id.json', async (req, res) => {
182
+ const { type, id } = req.params;
183
+ try {
184
+ const streams = await fetchUpstream(type, id);
185
+ const sorted = sortAndGroup(streams, type);
186
+ res.json({ streams: sorted });
187
+ } catch (e) {
188
+ res.json({ streams: [] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  }
190
+ });
191
 
192
+ app.get('/debug/:type/:id.json', async (req, res) => {
193
+ const { type, id } = req.params;
 
 
194
  try {
195
+ const streams = await fetchUpstream(type, id);
196
+ res.json({ streams });
197
+ } catch (e) {
198
+ res.status(500).json({ error: e.message });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  }
200
+ });
201
 
202
+ app.listen(PORT, () => console.log(`Ghostream 🚀 on ${PORT}`));