vanseleem commited on
Commit
c63b640
·
verified ·
1 Parent(s): 48c3478

Update index.js

Browse files
Files changed (1) hide show
  1. index.js +434 -169
index.js CHANGED
@@ -1,203 +1,468 @@
 
1
 
2
  const express = require('express');
3
- const fetch = require('node-fetch');
 
 
 
4
 
5
- const app = express();
6
- const PORT = process.env.PORT || 7860;
 
7
 
8
  app.use((req, res, next) => {
9
- res.header('Access-Control-Allow-Origin', '*');
10
- res.header('Access-Control-Allow-Headers', '*');
11
- res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
12
  if (req.method === 'OPTIONS') return res.sendStatus(200);
13
  next();
14
  });
15
 
16
- const UPSTREAM_URLS = [
17
- 'https://torrentio.strem.fun',
18
- 'https://torrentio.stremio.my.id'
19
- ];
20
 
21
- const MIN_SEEDERS = 1;
22
- const ALLOWED_QUALITIES = ['480p', '576p', '720p', '1080p', 'webrip', 'webdl', 'bluray'];
23
- const MAX_SIZE_GB_MOVIE = 4;
24
- const MAX_SIZE_GB_SERIES = 1.5;
25
-
26
- const SOURCE_CONFIG = [
27
- { name: 'yts', display: 'YTS', order: 1 },
28
- { name: 'yify', display: 'YTS', order: 1 },
29
- { name: 'eztv', display: 'EZTV', order: 2 },
30
- { name: 'nyaasi', display: 'Nyaa', order: 3 },
31
- { name: 'thepiratebay', display: 'TPB', order: 4 },
32
- { name: 'piratebay', display: 'TPB', order: 4 },
33
- { name: 'the piratebay', display: 'TPB', order: 4 },
34
- { name: 'tpb', display: 'TPB', order: 4 },
35
- { name: '1337x', display: '1337x', order: 5 },
36
- { name: 'torrentgalaxy', display: 'TGx', order: 6 },
37
- { name: 'rarbg', display: 'RARBG', order: 7 },
38
- { name: 'rutor', display: 'Rutor', order: 8 },
39
- { name: 'rutracker', display: 'Rutracker', order: 9 }
40
- ];
41
 
42
- const QUALITY_RANK = {
43
- '1080p': 1,
44
- '720p': 2,
45
- 'webdl': 3,
46
- 'webrip': 4,
47
- '576p': 5,
48
- '480p': 6,
49
- 'bluray': 7
50
- };
51
-
52
- const LINKS_PER_QUALITY = 5;
53
-
54
- function getQuality(title = '') {
55
- const lower = title.toLowerCase();
56
- if (lower.includes('1080p')) return '1080p';
57
- if (lower.includes('720p')) return '720p';
58
- if (lower.includes('480p')) return '480p';
59
- if (lower.includes('576p')) return '576p';
60
- if (lower.includes('webrip')) return 'webrip';
61
- if (lower.includes('webdl') || lower.includes('web-dl')) return 'webdl';
62
- if (lower.includes('bluray')) return 'bluray';
63
- return null;
64
- }
65
 
66
- function getSeeders(stream) {
67
- if (stream.behaviorHints?.seeders) return stream.behaviorHints.seeders;
68
- const match = stream.title?.match(/👤\s*(\d+)/);
69
- return match ? parseInt(match[1], 10) : 0;
 
 
 
70
  }
71
 
72
- function getSizeInGB(stream) {
73
- const title = stream.title || '';
74
- const sizeMatch = title.match(/💾\s*([\d.]+)\s*(GB|MB)/i) ||
75
- title.match(/([\d.]+)\s*(GB|MB)/i);
76
- if (!sizeMatch) return null;
77
-
78
- const value = parseFloat(sizeMatch[1]);
79
- const unit = sizeMatch[2].toUpperCase();
80
-
81
- if (unit === 'GB') return value;
82
- if (unit === 'MB') return value / 1024;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  return null;
84
  }
85
 
86
- function detectSource(title = '') {
87
- const lower = title.toLowerCase();
88
- for (const src of SOURCE_CONFIG) {
89
- if (lower.includes(src.name)) return src.name;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  return null;
92
  }
93
 
94
- function getSourceOrder(sourceName) {
95
- const config = SOURCE_CONFIG.find(c => c.name === sourceName);
96
- return config ? config.order : 999;
97
- }
98
-
99
- function filterStream(stream, type = 'movie') {
100
- const title = stream.title || '';
101
- if (!stream.url && !stream.infoHash) return false;
102
-
103
- const quality = getQuality(title);
104
- if (!ALLOWED_QUALITIES.includes(quality)) return false;
105
-
106
- if (getSeeders(stream) < MIN_SEEDERS) return false;
107
-
108
- if (!detectSource(title)) return false;
109
-
110
- const maxSize = type === 'series' ? MAX_SIZE_GB_SERIES : MAX_SIZE_GB_MOVIE;
111
- const sizeGB = getSizeInGB(stream);
112
- if (sizeGB !== null && sizeGB > maxSize) return false;
113
-
114
- return true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  }
116
 
117
- async function fetchUpstream(type, id) {
118
- for (const baseUrl of UPSTREAM_URLS) {
119
- try {
120
- const controller = new AbortController();
121
- const timeout = setTimeout(() => controller.abort(), 8000);
122
- const res = await fetch(`${baseUrl}/stream/${type}/${id}.json`, { signal: controller.signal });
123
- clearTimeout(timeout);
124
- if (res.ok) {
125
- const data = await res.json();
126
- if (data.streams?.length) return data.streams;
127
- }
128
- } catch (e) {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  }
130
- return [];
 
 
 
 
 
 
 
 
131
  }
132
 
133
- function sortAndGroup(streams, type = 'movie') {
134
- const filtered = streams.filter(s => filterStream(s, type));
135
-
136
- const byQuality = {};
137
- filtered.forEach(stream => {
138
- const quality = getQuality(stream.title);
139
- if (!byQuality[quality]) byQuality[quality] = [];
140
- byQuality[quality].push(stream);
141
- });
142
-
143
- for (const quality in byQuality) {
144
- byQuality[quality].sort((a, b) => {
145
- const sourceA = detectSource(a.title);
146
- const sourceB = detectSource(b.title);
147
- const orderA = getSourceOrder(sourceA);
148
- const orderB = getSourceOrder(sourceB);
149
- if (orderA !== orderB) return orderA - orderB;
150
- return getSeeders(b) - getSeeders(a);
151
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  }
153
-
154
- const result = [];
155
- const sortedQualities = Object.keys(byQuality).sort((a, b) => {
156
- const rankA = QUALITY_RANK[a] || 999;
157
- const rankB = QUALITY_RANK[b] || 999;
158
- return rankA - rankB;
159
- });
160
-
161
- sortedQualities.forEach(quality => {
162
- const topLinks = byQuality[quality].slice(0, LINKS_PER_QUALITY);
163
- result.push(...topLinks);
164
  });
165
-
166
- return result;
167
- }
168
-
169
- app.get('/manifest.json', (req, res) => {
170
- res.json({
171
- id: 'org.ghostream.platinum',
172
- name: 'Ghostream 🚀',
173
- description: 'Stream Everything You Want\nMade With 🤍 By Van',
174
- version: '7.3.0',
175
- resources: ['stream'],
176
- types: ['movie', 'series'],
177
- idPrefixes: ['tt'],
178
- catalogs: []
179
  });
180
- });
181
 
182
- app.get('/stream/:type/:id.json', async (req, res) => {
183
- const { type, id } = req.params;
184
- try {
185
- const streams = await fetchUpstream(type, id);
186
- const sorted = sortAndGroup(streams, type);
187
- res.json({ streams: sorted });
188
- } catch (e) {
189
- res.json({ streams: [] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  }
191
- });
192
 
193
- app.get('/debug/:type/:id.json', async (req, res) => {
194
- const { type, id } = req.params;
195
- try {
196
- const streams = await fetchUpstream(type, id);
197
- res.json({ streams });
198
- } catch (e) {
199
- res.status(500).json({ error: e.message });
200
  }
201
- });
202
 
203
- app.listen(PORT, () => console.log(`Ghostream 🚀 on ${PORT}`));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_TlE9rP7MTKE7PamqR6Ha7hlBjQZ3ET5zCZAYJCOIjG8';
 
 
 
22
 
23
+ app.get('/', (req, res) => res.send('Van Subs v15 ✅ Online'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
+ app.get('/manifest.json', (req, res) => res.json({
26
+ id: 'org.van.humansubtitles',
27
+ name: 'VanSubs+',
28
+ description: 'Human-Verified Arabic & English Subtitles.',
29
+ version: '15.0.0',
30
+ resources: ['subtitles'],
31
+ types: ['movie', 'series'],
32
+ idPrefixes: ['tt'],
33
+ catalogs: []
34
+ }));
35
+
36
+ app.get('/subtitles/:type/:id.json', (req, res) => handleRequest(req, res));
37
+ app.get('/subtitles/:type/:id/:extra.json', (req, res) => handleRequest(req, res));
38
+
39
+ app.get('/health', (req, res) => res.json({
40
+ status: 'ok',
41
+ version: '15.0.0',
42
+ cache: cache.size,
43
+ zips: subStore.size,
44
+ uptime: Math.floor(process.uptime()) + 's',
45
+ base_url: BASE_URL
46
+ }));
 
47
 
48
+ const cache = new Map();
49
+ const TTL_MOVIE = 7 * 24 * 60 * 60 * 1000;
50
+ const TTL_EPISODE = 8 * 60 * 60 * 1000;
51
+
52
+ function getTTL(type) { return type === 'series' ? TTL_EPISODE : TTL_MOVIE; }
53
+ function cacheKey(imdbId, type, season, episode) {
54
+ return `${imdbId}|${type}|${season ?? 'x'}|${episode ?? 'x'}`;
55
  }
56
 
57
+ setInterval(() => {
58
+ const now = Date.now(); let n = 0;
59
+ for (const [k, v] of cache.entries()) { if (now - v.ts > v.ttl) { cache.delete(k); n++; } }
60
+ if (n > 0) console.log(`[Cache] Evicted ${n} stale entries`);
61
+ }, 15 * 60 * 1000);
62
+
63
+ const subStore = new Map();
64
+ let subSeq = 0;
65
+
66
+ setInterval(() => {
67
+ if (subStore.size > 500) {
68
+ const keys = [...subStore.keys()].slice(0, 250);
69
+ keys.forEach(k => subStore.delete(k));
70
+ console.log('[ZipStore] Flushed 250 old entries');
71
+ }
72
+ }, 30 * 60 * 1000);
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Extract episode number from a filename (SxxExx → number, else null)
76
+ // ---------------------------------------------------------------------------
77
+ function episodeFromFilename(filename) {
78
+ if (!filename) return null;
79
+ const n = filename.toLowerCase();
80
+ const m1 = n.match(/s\d{1,2}e(\d{1,3})/);
81
+ if (m1) return parseInt(m1[1], 10);
82
+ const m2 = n.match(/[._\-\s]e(\d{1,3})[._\-\s]/);
83
+ if (m2) return parseInt(m2[1], 10);
84
  return null;
85
  }
86
 
87
+ // ---------------------------------------------------------------------------
88
+ // Pick file from ZIP – prefers exact episode match for series
89
+ // ---------------------------------------------------------------------------
90
+ function pickFileFromZip(zip, lang, season, episode) {
91
+ const entries = zip.getEntries()
92
+ .filter(e => !e.isDirectory)
93
+ .filter(e => /\.(srt|ass|ssa|vtt)$/i.test(e.entryName));
94
+ if (!entries.length) return null;
95
+
96
+ if (season != null && episode != null) {
97
+ const target = `s${String(season).padStart(2,'0')}e${String(episode).padStart(2,'0')}`;
98
+ const exact = entries.find(e => e.entryName.toLowerCase().includes(target));
99
+ if (exact) return exact;
100
+ const loose = `s${season}e${episode}`;
101
+ if (loose !== target) {
102
+ const looseMatch = entries.find(e => e.entryName.toLowerCase().includes(loose));
103
+ if (looseMatch) return looseMatch;
104
+ }
105
  }
106
+
107
+ const hints = lang === 'Arabic'
108
+ ? ['arabic', 'arab', '.ar.', '_ar_', '.ara.', '_ara_', 'ar.srt', 'ar.ass']
109
+ : ['english', 'eng', '.en.', '_en_', '.eng.', '_eng_', 'en.srt', 'en.ass'];
110
+
111
+ const hinted = entries.find(e => hints.some(h => e.entryName.toLowerCase().includes(h)));
112
+ if (hinted) return hinted;
113
+ if (entries.length === 1) return entries[0];
114
+
115
+ return [...entries].sort((a, b) =>
116
+ lang === 'Arabic' ? a.header.size - b.header.size : b.header.size - a.header.size
117
+ )[0];
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Resolve a subdl URL (direct or ZIP). Returns { url, extractedName }
122
+ // ---------------------------------------------------------------------------
123
+ async function resolveUrl(rawUrl, lang, season, episode) {
124
+ if (/\.(srt|ass|ssa|vtt)(\?|$)/i.test(rawUrl)) {
125
+ return { url: rawUrl, extractedName: null };
126
+ }
127
+ try {
128
+ const r = await timedFetch(rawUrl, {}, 8000);
129
+ if (!r.ok) { console.error(`[ZIP] ${r.status}`); return null; }
130
+ const zip = new AdmZip(await r.buffer());
131
+ const best = pickFileFromZip(zip, lang, season, episode);
132
+ if (!best) return null;
133
+ const ext = best.entryName.split('.').pop().toLowerCase();
134
+ const id = `s${++subSeq}`;
135
+ subStore.set(id, { buffer: best.getData(), ext });
136
+ const publicUrl = `${BASE_URL}/sub/${id}.${ext}`;
137
+ console.log(`[ZIP] "${best.entryName}" (${best.header.size}B) lang=${lang} → /sub/${id}`);
138
+ return { url: publicUrl, extractedName: best.entryName };
139
+ } catch (e) { console.error('[ZIP] error:', e.message); return null; }
140
+ }
141
+
142
+ app.get('/sub/:id', (req, res) => {
143
+ const id = req.params.id.replace(/\.[^.]+$/, '');
144
+ const item = subStore.get(id);
145
+ if (!item) return res.status(404).send('Not found');
146
+ const mime = item.ext === 'vtt' ? 'text/vtt' : 'text/plain';
147
+ res.setHeader('Content-Type', `${mime}; charset=utf-8`);
148
+ res.setHeader('Content-Disposition', `inline; filename="sub.${item.ext}"`);
149
+ res.send(item.buffer);
150
+ });
151
+
152
+ const ARABIC_CODES = new Set(['ar', 'ara', 'arabic']);
153
+ const ENGLISH_CODES = new Set(['en', 'eng', 'english']);
154
+
155
+ function resolveLanguage(code) {
156
+ const c = (code || '').toLowerCase().trim();
157
+ if (ARABIC_CODES.has(c)) return 'Arabic';
158
+ if (ENGLISH_CODES.has(c)) return 'English';
159
  return null;
160
  }
161
 
162
+ const FOREIGN_SIGNALS = [
163
+ 'spanish','español','espanol','latino','castellano','.es.','_es_','.spa.','_spa_','.lat.','_lat_','[lat]','(lat)',
164
+ 'french','français','francais','.fr.','_fr_','.fre.','_fre_',
165
+ 'portuguese','português','brazil','brasil','.pt.','_pt.','.por.','_por_','.br.','_br_',
166
+ 'german','deutsch','.de.','_de_','.ger.','_ger_',
167
+ 'italian','italiano','.it.','_it_','.ita.','_ita_',
168
+ 'dutch','nederlands','.nl.','_nl_','.dut.','_dut_','[nl]',
169
+ 'turkish','türkçe','.tr.','_tr_','.tur.','_tur_',
170
+ 'russian','.ru.','_ru_','.rus.','_rus_',
171
+ 'chinese','.zh.','_zh_','korean','.ko.','_ko_','hindi','.hi.','_hi_',
172
+ ];
173
+
174
+ function isForeignFilename(filename, lang) {
175
+ if (lang === 'Arabic') return false;
176
+ const n = (filename || '').toLowerCase();
177
+ return FOREIGN_SIGNALS.some(s => n.includes(s));
178
+ }
179
+
180
+ const AI_GARBAGE = [
181
+ 'ai-translated','machine translation','chatgpt','translated from','auto-translated',
182
+ 'google translate','deepl','whisper','ai generated','machine-translated','openai',
183
+ 'gpt-4','gpt4','auto translate','ai.translated','aitranslated','autotranslated',
184
+ 'machine.translation','subscene-ai','opensubtitles-bot','mtranslated','mt.translated',
185
+ 'ai_translated','translated.by.ai','translated_by_ai',
186
+ ];
187
+
188
+ function isHumanVerified(filename) {
189
+ const n = (filename || '').toLowerCase();
190
+ return !AI_GARBAGE.some(f => n.includes(f));
191
+ }
192
+
193
+ const SCENE_GROUPS = [
194
+ 'qxr','psa','galaxyrg','ntb','deflate','epsilon','tigole','framestor',
195
+ 'yts','yify','rarbg','fgt','amzn','flux','cm','tepes','heve','evo','ion10','glhf'
196
+ ];
197
+ const SOURCE_TAGS = ['bluray','blu-ray','webdl','web-dl','webrip','web-rip','dvdrip','hdrip','remux','bdremux','bdrip'];
198
+ const CODEC_TAGS = ['x265','x264','hevc','h265','h264','10bit','10-bit','hdr','hdr10','dv','dolbyvision','2160p','4k','uhd','1080p','1080','720p'];
199
+ const AUDIO_TAGS = ['truehd.atmos','truehd','atmos','ddp5.1','ddp5','ddp','dts-hd','dts.hd','dts','ac3','aac','eac3'];
200
+ const SYNC_BOOST = ['sync','synced','corrected','fixed','updated','proper','repack','retail','remux','bd.sync','bluray.sync'];
201
+ const SYNC_PENALTY = ['unsynced','unsync','rough','workprint','not.synced','not_synced','notsynced','raw.sub','raw_sub'];
202
+
203
+ function normalizeRelease(name) {
204
+ if (!name) return [];
205
+ return name.toLowerCase()
206
+ .replace(/\[.*?\]/g, ' ').replace(/\(.*?\)/g, ' ')
207
+ .replace(/[^a-z0-9]+/g, ' ').trim().split(/\s+/).filter(t => t.length > 1);
208
  }
209
 
210
+ // ---------------------------------------------------------------------------
211
+ // Score a subtitle release. If season/episode are given, a 500‑point bonus
212
+ // is added when the filename contains the exact SxxExx pattern.
213
+ // ---------------------------------------------------------------------------
214
+ function scoreTrack(subFilename, streamTitle, rating = 0, season = null, episode = null) {
215
+ if (!subFilename) return 0;
216
+ const n = subFilename.toLowerCase();
217
+ const subTokens = normalizeRelease(subFilename);
218
+ const strmTokens = normalizeRelease(streamTitle || '');
219
+ let s = 0;
220
+
221
+ // Episode exact match boost
222
+ if (season != null && episode != null) {
223
+ const pat = `s${String(season).padStart(2,'0')}e${String(episode).padStart(2,'0')}`;
224
+ if (n.includes(pat)) s += 500;
225
+ else {
226
+ const loose = `s${season}e${episode}`;
227
+ if (loose !== pat && n.includes(loose)) s += 500;
228
+ }
229
+ }
230
+
231
+ for (const g of SCENE_GROUPS) {
232
+ const inSub = subTokens.includes(g);
233
+ const inStrm = strmTokens.includes(g);
234
+ if (inSub && inStrm) s += 200;
235
+ else if (inSub) s += 60;
236
  }
237
+
238
+ SOURCE_TAGS.forEach((t, i) => { if (n.includes(t)) s += (SOURCE_TAGS.length - i) * 8; });
239
+ CODEC_TAGS.forEach( (t, i) => { if (n.includes(t)) s += (CODEC_TAGS.length - i) * 6; });
240
+ AUDIO_TAGS.forEach( (t, i) => { if (n.includes(t)) s += (AUDIO_TAGS.length - i) * 5; });
241
+ SYNC_BOOST.forEach( b => { if (n.includes(b)) s += 35; });
242
+ SYNC_PENALTY.forEach(p => { if (n.includes(p)) s -= 50; });
243
+ if (rating > 0) s += Math.round(rating * 2);
244
+
245
+ return s;
246
  }
247
 
248
+ async function timedFetch(url, opts = {}, ms = 6000) {
249
+ const ctrl = new AbortController();
250
+ const timer = setTimeout(() => ctrl.abort(), ms);
251
+ try { return await fetch(url, { ...opts, signal: ctrl.signal }); }
252
+ finally { clearTimeout(timer); }
253
+ }
254
+
255
+ async function safeJson(r, tag) {
256
+ const text = await r.text();
257
+ try { return JSON.parse(text); }
258
+ catch { console.error(`[${tag}] bad JSON:`, text.slice(0, 200)); return null; }
259
+ }
260
+
261
+ async function handleRequest(req, res) {
262
+ const { type } = req.params;
263
+ const rawId = req.params.id.replace('.json', '');
264
+ const extra = req.params.extra ? decodeURIComponent(req.params.extra.replace('.json','')) : '';
265
+ const parts = rawId.split(':');
266
+ const imdbId = parts[0];
267
+ const season = parts[1] ? parseInt(parts[1]) : null;
268
+ const episode = parts[2] ? parseInt(parts[2]) : null;
269
+
270
+ const filenameMatch = extra.match(/filename=([^&]+)/i);
271
+ const streamTitle = filenameMatch ? decodeURIComponent(filenameMatch[1]) : '';
272
+
273
+ console.log(`\n╔═ [VanSubs v15] ${type.toUpperCase()} ${imdbId}` +
274
+ (season !== null ? ` S${String(season).padStart(2,'0')}E${String(episode??0).padStart(2,'0')}` : '') +
275
+ (streamTitle ? `\n║ hint: "${streamTitle}"` : ''));
276
+
277
+ const ck = cacheKey(imdbId, type, season, episode);
278
+ const hit = cache.get(ck);
279
+ if (hit && (Date.now() - hit.ts) < hit.ttl) {
280
+ console.log(`╚═ ✅ Cache hit — ${hit.subtitles.length} tracks`);
281
+ return res.json({ subtitles: hit.subtitles });
282
  }
283
+
284
+ let rawTracks = [];
285
+ try { rawTracks = await fetchSubDL(imdbId, type, season, episode); }
286
+ catch (e) { console.error('║ [SubDL] FAILED:', e.message); }
287
+ console.log(`║ [SubDL] ${rawTracks.length} raw tracks`);
288
+
289
+ const seen = new Set();
290
+ const deduped = rawTracks.filter(s => {
291
+ const key = (s.filename || '').toLowerCase().replace(/[\s.\-_]+/g, '.').replace(/\.(srt|ass|zip)$/, '');
292
+ if (seen.has(key)) return false;
293
+ seen.add(key); return true;
294
  });
295
+
296
+ const clean = deduped.filter(s => {
297
+ if (!s.url) return false;
298
+ if (!isHumanVerified(s.filename)) { console.log(`║ [AI-DROP] "${s.filename}"`); return false; }
299
+ if (isForeignFilename(s.filename, s.lang)) { console.log(`║ [FOREIGN-DROP] "${s.filename}"`); return false; }
300
+ return true;
 
 
 
 
 
 
 
 
301
  });
 
302
 
303
+ // Phase 1: order by release quality only (no episode boost)
304
+ const scored = clean
305
+ .map(s => ({ ...s, score: scoreTrack(s.filename, streamTitle, s.rating) }))
306
+ .sort((a, b) => b.score - a.score);
307
+
308
+ const arabic = scored.filter(s => s.lang === 'Arabic').slice(0, 4);
309
+ const english = scored.filter(s => s.lang === 'English').slice(0, 4);
310
+ const final = [...arabic, ...english];
311
+
312
+ // Phase 2: resolve URLs, replace filename with extracted name, re‑score
313
+ const resolved = (await Promise.all(
314
+ final.map(async s => {
315
+ const result = await resolveUrl(s.url, s.lang, season, episode);
316
+ if (!result) return null;
317
+ // Replace the release filename with the actual file we serve
318
+ const displayName = result.extractedName || s.filename;
319
+ return {
320
+ ...s,
321
+ url: result.url,
322
+ filename: displayName,
323
+ };
324
+ })
325
+ )).filter(Boolean);
326
+
327
+ // Phase 3: re‑score with the correct (possibly extracted) filename, now with episode boost
328
+ const finalSorted = resolved
329
+ .map(s => ({
330
+ ...s,
331
+ finalScore: scoreTrack(s.filename, streamTitle, s.rating, season, episode)
332
+ }))
333
+ .sort((a, b) => b.finalScore - a.finalScore);
334
+
335
+ const subtitles = finalSorted.map((s, i) => ({
336
+ id: `van_${s.lang === 'Arabic' ? 'ar' : 'en'}_${imdbId}_${i}`,
337
+ url: s.url,
338
+ lang: s.lang
339
+ }));
340
+
341
+ cache.set(ck, { subtitles, ts: Date.now(), ttl: getTTL(type) });
342
+
343
+ const ar = finalSorted.filter(s => s.lang === 'Arabic');
344
+ const en = finalSorted.filter(s => s.lang === 'English');
345
+ console.log(`║ ✅ ${subtitles.length} tracks (AR:${ar.length} EN:${en.length})`);
346
+ ar.forEach((s, i) => console.log(`║ AR[${i}] ${String(s.finalScore).padStart(4)}pt "${s.filename}"`));
347
+ en.forEach((s, i) => console.log(`║ EN[${i}] ${String(s.finalScore).padStart(4)}pt "${s.filename}"`));
348
+ console.log(`╚═ done`);
349
+
350
+ res.json({ subtitles });
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // SubDL API helper with fallback: if a request with type=tv returns 0
355
+ // results, retry without it (the API sometimes fails with type=tv).
356
+ // ---------------------------------------------------------------------------
357
+ async function subDLRequestWithFallback(baseParams) {
358
+ // Try with type=tv first
359
+ let data = await subDLRequest(baseParams + '&type=tv');
360
+ if (data && data.length > 0) return data;
361
+ // Fallback without type=tv
362
+ return await subDLRequest(baseParams);
363
+ }
364
+
365
+ async function subDLRequest(params) {
366
+ const base = `https://api.subdl.com/api/v1/subtitles?api_key=${SUBDL_KEY}&languages=AR,EN&subs_per_page=30&releases=1`;
367
+ const url = base + params;
368
+ const r = await timedFetch(url, {}, 6000);
369
+ const data = await safeJson(r, 'SubDL');
370
+ if (!data?.status) { console.error('║ [SubDL] error:', data?.error); return []; }
371
+ return data.subtitles || [];
372
+ }
373
+
374
+ function parseSubDLResults(subs) {
375
+ return subs.map(sub => {
376
+ const lang = resolveLanguage(sub.language);
377
+ if (!lang) return null;
378
+ let dlUrl = (sub.url || '').trim();
379
+ if (dlUrl && !dlUrl.startsWith('http')) dlUrl = 'https://dl.subdl.com' + dlUrl;
380
+ if (!dlUrl) return null;
381
+ return {
382
+ filename: sub.release_name || sub.full_name || sub.name || 'SubDL Track',
383
+ url: dlUrl,
384
+ lang,
385
+ rating: parseFloat(sub.rating) || 0,
386
+ source: 'subdl',
387
+ season: sub.season ?? null,
388
+ episode: sub.episode ?? null,
389
+ episodeFrom: sub.episode_from ?? null,
390
+ episodeTo: sub.episode_end ?? null,
391
+ fullSeason: sub.full_season ?? false,
392
+ };
393
+ }).filter(Boolean);
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Client‑side episode filter
398
+ // ---------------------------------------------------------------------------
399
+ function episodeFilter(s, targetSeason, targetEp, callSite) {
400
+ const seasonOk = !s.season || s.season === 0 || s.season === targetSeason;
401
+ if (!seasonOk) {
402
+ console.log(`║ [EP-FILTER] dropped wrong-season from ${callSite}: "${s.filename}"`);
403
+ return false;
404
  }
 
405
 
406
+ if (s.episodeFrom > 0 && s.episodeTo > 0) {
407
+ const inRange = targetEp >= s.episodeFrom && targetEp <= s.episodeTo;
408
+ if (!inRange) console.log(`║ [EP-FILTER] dropped out-of-range from ${callSite}: "${s.filename}"`);
409
+ return inRange;
 
 
 
410
  }
 
411
 
412
+ if (s.fullSeason) {
413
+ const fnEp = episodeFromFilename(s.filename);
414
+ if (fnEp !== null && fnEp !== targetEp) {
415
+ console.log(`║ [EP-FILTER] dropped wrong-episode (fullSeason, filename) from ${callSite}: "${s.filename}"`);
416
+ return false;
417
+ }
418
+ return true;
419
+ }
420
+
421
+ if (s.episode !== null && s.episode !== 0) {
422
+ const match = s.episode === targetEp;
423
+ if (!match) console.log(`║ [EP-FILTER] dropped wrong-episode from ${callSite}: "${s.filename}"`);
424
+ return match;
425
+ }
426
+
427
+ const fnEp = episodeFromFilename(s.filename);
428
+ if (fnEp !== null && fnEp !== targetEp) {
429
+ console.log(`║ [EP-FILTER] dropped wrong-episode (filename) from ${callSite}: "${s.filename}"`);
430
+ return false;
431
+ }
432
+
433
+ return true;
434
+ }
435
+
436
+ async function fetchSubDL(imdbId, type, season, episode) {
437
+ if (type !== 'series' || season === null || episode === null) {
438
+ const subs = await subDLRequest(`&imdb_id=${imdbId}`);
439
+ return parseSubDLResults(subs);
440
+ }
441
+
442
+ const [episodeSubs, seasonSubs] = await Promise.all([
443
+ subDLRequestWithFallback(`&imdb_id=${imdbId}&season_number=${season}&episode_number=${episode}`),
444
+ subDLRequestWithFallback(`&imdb_id=${imdbId}&season_number=${season}&full_season=1`)
445
+ ]);
446
+
447
+ const episodeParsed = parseSubDLResults(episodeSubs)
448
+ .filter(s => episodeFilter(s, season, episode, 'episodeCall'));
449
+
450
+ const seasonParsed = parseSubDLResults(seasonSubs)
451
+ .filter(s => episodeFilter(s, season, episode, 'seasonCall'));
452
+
453
+ console.log(`║ [SubDL] episode=${episodeParsed.length} season_pack=${seasonParsed.length}`);
454
+
455
+ const combined = [...episodeParsed, ...seasonParsed];
456
+ const seen = new Set();
457
+ return combined.filter(s => {
458
+ const key = (s.filename || '').toLowerCase().replace(/[\s.\-_]+/g, '.');
459
+ if (seen.has(key)) return false;
460
+ seen.add(key); return true;
461
+ });
462
+ }
463
+
464
+ app.listen(PORT, '0.0.0.0', () => {
465
+ console.log(`\nVan Subs v15 — port ${PORT}`);
466
+ console.log(`BASE_URL: ${BASE_URL}`);
467
+ console.log(`SPACE_HOST env: ${process.env.SPACE_HOST || '(not set — using localhost)'}\n`);
468
+ });