vanseleem commited on
Commit
58b9acd
Β·
verified Β·
1 Parent(s): 532f1da

Update index.js

Browse files
Files changed (1) hide show
  1. index.js +412 -395
index.js CHANGED
@@ -1,5 +1,3 @@
1
- 'use strict';
2
-
3
  const express = require('express');
4
  const cheerio = require('cheerio');
5
  const AdmZip = require('adm-zip');
@@ -12,98 +10,140 @@ const app = express();
12
  const PORT = process.env.PORT || 7860;
13
 
14
  app.use((req, res, next) => {
15
- res.header('Access-Control-Allow-Origin', '*');
16
  res.header('Access-Control-Allow-Headers', '*');
17
  res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
18
  if (req.method === 'OPTIONS') return res.sendStatus(200);
19
  next();
20
  });
21
 
22
- /* ===================================================================
23
- CONFIG
24
- =================================================================== */
25
  const BASE = 'https://subf2m.co';
26
- const TMDB_KEY = '30166e2db6b420d3f230808cd1bb2c90'; // you can replace with your own
27
  const TMDB_BASE = 'https://api.themoviedb.org/3';
28
  const MAX_PER_LANG = 4;
29
  const CACHE_TTL = 6 * 60 * 60 * 1000;
30
  const REQ_TIMEOUT = 15_000;
31
  const DL_TIMEOUT = 25_000;
32
- const MAX_DETAIL = 50;
 
 
 
 
 
 
33
 
 
34
  const ALLOWED_LANGS = new Set(['english', 'arabic']);
35
 
36
- /* ===================================================================
37
- AI FILTERS (unchanged)
38
- =================================================================== */
39
- const AI_PATTERNS = [
40
- /\ba\.i\.\b/i, /\bai[\s\-_]sub(title)?s?\b/i, /\bai[\s\-_](translat|generat|correct|made|creat)/i,
41
- /(translat|generat|creat|made|sub(title)?s?)\s+(by\s+)?ai\b/i, /machine[\s\-_]translat/i, /auto[\s\-_]?translat/i,
42
- /auto[\s\-_]?generat/i, /auto[\s\-_]?sub(title)?s?\b/i, /\bautosub\b/i, /\bchatgpt\b/i, /\bgpt[\s\-_]?[34o]?\b/i,
43
- /\bdeep[\s\-_]?l\b/i, /google[\s\-_]translat/i, /yandex[\s\-_]translat/i, /amazon[\s\-_]translat/i,
44
- /microsoft[\s\-_]translat/i, /bing[\s\-_]translat/i, /\bopenai\b/i, /\bwhisper\b/i, /\bgemini\b/i, /\bclaude\b/i,
45
- /\bllama\b/i, /neural[\s\-_](translat|sub|net|machine)/i, /\bnmt\b/i, /artificial[\s\-_]intelligence/i,
46
- /computer[\s\-_](translat|generat)/i,
47
  ];
48
- const isAISub = (text) => AI_PATTERNS.some(rx => rx.test(text || ''));
49
-
50
- /* ===================================================================
51
- GLOBALS & CACHE
52
- =================================================================== */
53
- const cache = new Map();
54
- const subStore = new Map();
55
- let subSeq = 0;
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
- setInterval(() => {
64
- if (subStore.size > 500) {
65
- const keys = [...subStore.keys()].slice(0, 250);
66
- keys.forEach(k => subStore.delete(k));
67
- console.log('[ZipStore] Flushed 250 old entries');
68
- }
69
- }, 30 * 60 * 1000);
70
- const sleep = ms => new Promise(r => setTimeout(r, ms));
71
 
72
- /* ===================================================================
73
- HTTP HELPERS
74
- =================================================================== */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  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';
76
 
77
- function _request(urlStr, opts = {}) {
78
  return new Promise((resolve, reject) => {
79
- const parsed = new URL(urlStr);
80
- const lib = parsed.protocol === 'https:' ? https : http;
81
- const timeout = opts.timeout || REQ_TIMEOUT;
82
- const reqOpts = {
83
  hostname: parsed.hostname,
84
  port : parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
85
  path : parsed.pathname + parsed.search,
86
  method : 'GET',
87
  headers : {
88
- 'User-Agent' : UA,
89
- 'Accept' : opts.binary ? 'application/zip,application/octet-stream,*/*' : 'text/html,application/xhtml+xml,*/*;q=0.8',
90
- 'Accept-Language' : 'en-US,en;q=0.9',
91
- 'Accept-Encoding' : 'gzip, deflate',
92
- 'Referer' : BASE + '/',
93
- 'Connection' : 'keep-alive',
94
- 'Cache-Control' : 'no-cache',
95
  ...(opts.headers || {})
96
  }
97
  };
98
- const req = lib.request(reqOpts, res => {
99
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
100
- const next = new URL(res.headers.location, urlStr).toString();
101
- return _request(next, opts).then(resolve).catch(reject);
102
  }
103
  if (res.statusCode === 404) return resolve(null);
104
  if (res.statusCode !== 200) return resolve(null);
105
  const chunks = [];
106
- let stream = res;
107
  const enc = res.headers['content-encoding'] || '';
108
  if (enc.includes('gzip')) stream = res.pipe(zlib.createGunzip());
109
  else if (enc.includes('br')) stream = res.pipe(zlib.createBrotliDecompress());
@@ -112,249 +152,198 @@ function _request(urlStr, opts = {}) {
112
  stream.on('end', () => resolve(opts.binary ? Buffer.concat(chunks) : Buffer.concat(chunks).toString('utf8')));
113
  stream.on('error', reject);
114
  });
115
- req.setTimeout(timeout, () => { req.destroy(); reject(new Error('Timeout')); });
116
  req.on('error', reject);
117
  req.end();
118
  });
119
  }
120
-
121
  async function getHTML(url, tries = 3) {
122
  for (let i = 0; i < tries; i++) {
123
- try { const r = await _request(url, { timeout: REQ_TIMEOUT }); if (r !== null) return r; } catch {}
124
- if (i < tries - 1) await sleep(1200 * (i + 1));
125
  }
126
  return null;
127
  }
128
-
129
- async function getBinary(url, tries = 3) {
130
  for (let i = 0; i < tries; i++) {
131
- try { const r = await _request(url, { binary: true, timeout: DL_TIMEOUT }); if (r !== null) return r; } catch {}
132
- if (i < tries - 1) await sleep(1200 * (i + 1));
133
  }
134
  return null;
135
  }
136
 
137
- /* ===================================================================
138
- CACHE HELPERS
139
- =================================================================== */
140
- function cacheGet(key) {
141
- const item = cache.get(key);
142
  if (!item) return null;
143
- if (Date.now() - item.ts > CACHE_TTL) { cache.delete(key); return null; }
144
  return item.val;
145
  }
146
- function cacheSet(key, val) {
147
- if (cache.size >= 900) { let n = 0; for (const k of cache.keys()) { cache.delete(k); if (++n >= 90) break; } }
148
- cache.set(key, { val, ts: Date.now() });
149
  }
 
 
150
 
151
- /* ===================================================================
152
- TMDB (for title / year metadata needed for candidate scoring)
153
- =================================================================== */
154
  async function getTMDB(imdbId) {
155
- const ck = `tmdb:${imdbId}`;
156
- const hit = cacheGet(ck);
157
  if (hit !== null) return hit;
158
  try {
159
- const raw = await _request(`${TMDB_BASE}/find/${imdbId}?external_source=imdb_id&api_key=${TMDB_KEY}`, { timeout: 8000 });
160
  if (!raw) { cacheSet(ck, null); return null; }
161
  const d = JSON.parse(raw);
162
  let info = null;
163
  if (d.movie_results?.length) {
164
  const m = d.movie_results[0];
165
- info = { title: m.title || m.original_title, year: m.release_date?.substring(0,4) || null, type: 'movie' };
166
  } else if (d.tv_results?.length) {
167
  const t = d.tv_results[0];
168
- info = { title: t.name || t.original_name, year: t.first_air_date?.substring(0,4) || null, type: 'series' };
169
  }
170
- cacheSet(ck, info);
171
- return info;
172
  } catch { cacheSet(ck, null); return null; }
173
  }
174
 
175
- /* ===================================================================
176
- CANDIDATE SCORING & SLUG SELECTION
177
- =================================================================== */
178
- function slugCandidateScore(slugText, expectedTitle, expectedYear) {
179
- // text is the cleaned title string from the search result (e.g. "My Name (2021)")
180
- const text = (slugText || '').trim().toLowerCase();
181
- const clean = text.replace(/\s*\(\d{4}\)\s*$/, '').trim(); // remove year part
182
- const yearMatch = text.match(/\((\d{4})\)/);
183
- const year = yearMatch ? yearMatch[1] : null;
184
-
185
- let score = 0;
186
-
187
- // Title similarity (simple token overlap)
188
- const expectedClean = (expectedTitle || '').toLowerCase().trim();
189
- const tokensA = new Set(clean.split(' ').filter(Boolean));
190
- const tokensB = new Set(expectedClean.split(' ').filter(Boolean));
191
- const common = [...tokensA].filter(t => tokensB.has(t)).length;
192
- const similarity = common / Math.max(tokensA.size, tokensB.size, 1);
193
- score += similarity * 100;
194
-
195
- // Year match (strong bonus)
196
- if (year && expectedYear && year === String(expectedYear)) score += 100;
197
- else if (year && expectedYear && Math.abs(+year - +expectedYear) <= 1) score += 40; // adjacent year
198
-
199
- // Prefer non-collection / non-complete-series titles (less junk)
200
- if (clean.includes('complete series') || clean.includes('collection')) score -= 30;
201
-
202
- return score;
203
- }
204
-
205
- async function resolveBestSlug(imdbId, season, tmdbTitle, tmdbYear) {
206
- const ck = `best:${imdbId}:${season ?? 'movie'}`;
207
  const hit = cacheGet(ck);
208
  if (hit !== null) return hit;
209
 
210
- const url = `${BASE}/subtitles/searchbytitle?query=${imdbId}&l=`;
211
- console.log(`[IMDb] Fetching candidate list: ${url}`);
 
 
 
 
 
 
 
 
 
212
  const html = await getHTML(url);
213
- if (!html) { cacheSet(ck, null); return null; }
214
 
215
  const $ = cheerio.load(html);
216
- const candidates = [];
 
217
 
218
- $('li div.title a').each((_, a) => {
219
- const href = $(a).attr('href') || '';
220
- if (!href.startsWith('/subtitles/')) return;
221
  const parts = href.split('/').filter(Boolean);
222
  if (parts.length < 2) return;
223
  const slug = parts[1];
224
- const rawText = $(a).text() || '';
225
- const text = rawText.trim(); // ← handle trailing spaces
226
- candidates.push({ slug, text });
 
 
227
  });
228
 
229
- if (!candidates.length) {
230
- console.log(`[IMDb] No candidates found`);
231
- cacheSet(ck, null);
232
- return null;
233
- }
234
- console.log(`[IMDb] Found ${candidates.length} candidates`);
235
-
236
- // Score candidates
237
- const scored = candidates.map(c => ({
238
- ...c,
239
- score: slugCandidateScore(c.text, tmdbTitle, tmdbYear)
240
- }));
241
- scored.sort((a, b) => b.score - a.score);
242
-
243
- // If season is requested, try to find a candidate containing the season word
244
- const SEASON_WORDS = [
245
- 'first season', 'second season', 'third season', 'fourth season', 'fifth season',
246
- 'sixth season', 'seventh season', 'eighth season', 'ninth season', 'tenth season',
247
- 'eleventh season', 'twelfth season', 'thirteenth season', 'fourteenth season',
248
- 'fifteenth season', 'sixteenth season', '1st season', '2nd season', '3rd season'
249
- ];
250
- let selectedSlug = scored[0].slug;
251
- if (season && season <= SEASON_WORDS.length) {
252
- const seasonWord = SEASON_WORDS[season - 1];
253
- if (seasonWord) {
254
- const match = scored.find(c => c.text.toLowerCase().includes(seasonWord));
255
- if (match) {
256
- selectedSlug = match.slug;
257
- console.log(`[IMDb] Season match (${seasonWord}): ${selectedSlug}`);
258
- } else {
259
- console.log(`[IMDb] No season match, using best scored slug: ${selectedSlug}`);
260
- }
261
  }
 
 
 
 
 
 
 
 
 
262
  }
263
 
264
- cacheSet(ck, selectedSlug);
265
- return selectedSlug;
266
- }
267
-
268
- /* ===================================================================
269
- EPISODE PARSING (token-level, handles SxxExx and XxYY formats)
270
- =================================================================== */
271
- function parseEpisode(text) {
272
- if (!text) return { season: null, episode: null };
273
- const t = text.toLowerCase();
274
- // SxxExx
275
- let m = t.match(/s(\d{1,2})[\.\-_\s]?e(\d{1,3})/);
276
- if (m) return { season: parseInt(m[1],10), episode: parseInt(m[2],10) };
277
- // XxYY (e.g. 1x05)
278
- m = t.match(/\b(\d{1,2})x(\d{2,3})\b/);
279
- if (m) return { season: parseInt(m[1],10), episode: parseInt(m[2],10) };
280
- // "Season X Episode Y"
281
- m = t.match(/season\s*(\d{1,2})\s*episode\s*(\d{1,3})/i);
282
- if (m) return { season: parseInt(m[1],10), episode: parseInt(m[2],10) };
283
- return { season: null, episode: null };
284
  }
285
 
286
- /* ===================================================================
287
- SUBTITLE SCRAPING & ENRICHMENT
288
- =================================================================== */
289
- function _clean(t) { return (t || '').replace(/\s+/g, ' ').trim(); }
290
-
291
- async function _enrichSub(sub) {
292
- const ck = `det:${sub.subId}`;
293
- const hit = cacheGet(ck);
294
- if (hit !== null) return hit.__aiBlocked ? null : { ...sub, ...hit };
295
-
296
- const html = await getHTML(sub.detailUrl);
297
- if (!html) return sub;
298
- const $ = cheerio.load(html);
299
- const txt = $.root().text();
300
-
301
- const goodVotes = +(txt.match(/voted\s+as\s+good\s+by[:\s]*(\d+)/i)?.[1] ?? 0);
302
- const badVotes = +(txt.match(/voted\s+as\s+bad\s+by[:\s]*(\d+)/i)?.[1] ?? 0);
303
- const downloads = +(txt.match(/downloads?[:\s]*(\d+)/i)?.[1] ?? 0);
304
- const total = goodVotes + badVotes;
305
- const ratio = total >= 3 ? goodVotes / total : null;
306
- const isGood = sub.isGood || (ratio !== null && ratio >= 0.70 && goodVotes >= 2);
307
-
308
- const authorNote = _clean($('p.comment, .comment, .notes, .author-comment').first().text());
309
- const fullCheck = `${authorNote} ${sub.releaseInfo} ${sub.comment}`;
310
- if (isAISub(fullCheck)) { cacheSet(ck, { __aiBlocked: true }); return null; }
311
-
312
- const relLines = $('ul.release li, .release-names li, ul.scrolllist li')
313
- .map((_, e) => $(e).text().trim()).get();
314
- const releaseInfo = relLines.length ? relLines.join(' | ').substring(0, 130) : sub.releaseInfo;
315
-
316
- return { ...sub, releaseInfo, goodVotes, badVotes, downloads, isGood, authorNote };
317
  }
318
 
 
319
  async function getSubsList(slug, lang, season, episode) {
320
  if (!ALLOWED_LANGS.has(lang)) return [];
321
-
322
- const ck = `list:${slug}:${lang}:${episode || 0}`;
323
  const hit = cacheGet(ck);
324
  if (hit) return hit;
325
 
326
- const url = `${BASE}/subtitles/${slug}/${lang}`;
327
- const html = await getHTML(url);
328
  if (!html) { cacheSet(ck, []); return []; }
329
 
330
  const $ = cheerio.load(html);
331
  let raw = [];
 
332
 
333
- $('li.item').each((_, el) => {
334
- const $el = $(el);
335
- const rawText = _clean($el.text());
336
- let detailHref = null;
337
- $el.find('a').each((_, a) => {
338
- const h = $(a).attr('href') || '';
339
- if (h.includes(`/${lang}/`) && /\/\d+$/.test(h)) detailHref = h;
340
- });
341
- if (!detailHref) return;
342
- const parts = detailHref.split('/');
343
- const subId = parts[parts.length - 1];
344
- if (!subId || !/^\d+$/.test(subId)) return;
345
-
346
- const isGood = $el.find('.green-icon, .positive-icon').length > 0 ||
347
- $el.hasClass('good') || ($el.attr('class') || '').toLowerCase().includes('good');
348
- const comment = _clean($el.find('p.comment, .comment').first().text());
349
- const releaseLines = $el.find('.r li, ul.release li, ul.scrolllist li, ul li')
350
- .map((_, li) => $(li).text().trim()).get().filter(Boolean);
351
- const releaseInfo = releaseLines.join(' | ').substring(0, 130) || _clean($el.text()).substring(0, 130);
352
- if (isAISub(`${comment} ${releaseInfo}`)) return;
353
- raw.push({ subId, detailUrl: detailHref.startsWith('http') ? detailHref : `${BASE}${detailHref}`,
354
- isGood, releaseInfo, comment, authorNote: '', lang, goodVotes: 0, badVotes: 0, downloads: 0,
355
- _rawText: rawText });
356
- });
357
-
358
  if (!raw.length) {
359
  $('a[href]').each((_, a) => {
360
  const href = $(a).attr('href') || '';
@@ -362,128 +351,143 @@ async function getSubsList(slug, lang, season, episode) {
362
  const parts = href.split('/');
363
  const subId = parts[parts.length - 1];
364
  if (!subId || !/^\d+$/.test(subId)) return;
365
- raw.push({ subId, detailUrl: href.startsWith('http') ? href : `${BASE}${href}`,
366
- isGood: false, releaseInfo: '', comment: '', authorNote: '', lang,
367
- goodVotes: 0, badVotes: 0, downloads: 0, _rawText: '' });
368
  });
369
  }
370
 
371
- // Episode filter – token‑level matching
372
- if (season && episode) {
373
- const exact = raw.filter(item => {
374
- const parsed = parseEpisode(item._rawText || item.releaseInfo);
375
- return parsed.season === season && parsed.episode === episode;
 
 
 
376
  });
377
- if (exact.length > 0) raw = exact;
378
- else console.log(`[ep-filter] No exact S${season}E${episode} match in ${lang}, falling back to all on page.`);
 
 
 
 
379
  }
380
 
381
- const seen = new Set();
382
- const unique = raw.filter(s => { if (seen.has(s.subId)) return false; seen.add(s.subId); return true; });
383
 
384
- const toEnrich = unique.slice(0, MAX_DETAIL);
385
- const enriched = (await Promise.all(toEnrich.map(s => _enrichSub(s)))).filter(Boolean);
 
 
386
 
387
- cacheSet(ck, enriched);
388
- return enriched;
389
- }
390
-
391
- /* ===================================================================
392
- SCORING (streaming source priority, sync keywords, etc.)
393
- =================================================================== */
394
- const OFFICIAL_SOURCES = ['netflix','nf','amzn','amazon','osn','shahid','wavo','hbo','hulu','disney','dsnp','appletv','atvp'];
395
-
396
- function scoreTrack(sub, streamTitle) {
397
- const text = (sub.releaseInfo || '').toLowerCase();
398
- let s = 0;
399
-
400
- if (OFFICIAL_SOURCES.some(src => text.includes(src))) s += 150;
401
- if (/\b(synced|resync|corrected|fixed|proper|repack|retail|sync\.fix)\b/i.test(text)) s += 80;
402
- if (/\b(sdh|hi\.|hi_|hearing.impaired)\b/i.test(text) || sub.hearingImpaired) s -= 40;
403
- if (sub.rating) s += Math.round((sub.rating || 0) * 4);
404
- if (/\.(ass|ssa)$/i.test(sub.releaseInfo || '')) s += 5;
405
-
406
- // Release group match between subtitle and stream filename
407
- const strmTitle = (streamTitle || '').toLowerCase();
408
- const subGroups = text.match(/\b(?:-([a-z0-9]{2,20}))\b/g) || [];
409
- for (const g of subGroups) {
410
- const group = g.replace(/^-/, '');
411
- if (group && strmTitle.includes(group)) { s += 100; break; }
412
- }
413
 
414
- return s;
 
415
  }
416
 
417
- /* ===================================================================
418
- ZIP HANDLING (parallel downloads)
419
- =================================================================== */
420
- function pickFileFromZip(zip, lang, season, episode) {
421
- const entries = zip.getEntries()
422
- .filter(e => !e.isDirectory)
423
- .filter(e => /\.(srt|ass|ssa|vtt)$/i.test(e.entryName));
424
- if (!entries.length) return null;
425
-
426
- if (season != null && episode != null) {
427
- const target = `s${String(season).padStart(2,'0')}e${String(episode).padStart(2,'0')}`;
428
- const exact = entries.find(e => e.entryName.toLowerCase().includes(target));
429
- if (exact) return exact;
430
- }
 
 
 
 
 
 
 
 
 
 
431
 
432
- const hints = lang === 'Arabic'
433
- ? ['arabic', 'arab', '.ar.', '_ar_', '.ara.', '_ara_', 'ar.srt', 'ar.ass']
434
- : ['english', 'eng', '.en.', '_en_', '.eng.', '_eng_', 'en.srt', 'en.ass'];
435
- const hinted = entries.find(e => hints.some(h => e.entryName.toLowerCase().includes(h)));
436
- if (hinted) return hinted;
437
- if (entries.length === 1) return entries[0];
438
- return [...entries].sort((a, b) =>
439
- lang === 'Arabic' ? a.header.size - b.header.size : b.header.size - a.header.size
440
- )[0];
 
 
 
 
 
 
 
 
 
 
441
  }
442
 
443
- async function resolveUrl(downloadUrl, lang, season, episode) {
444
- if (/\.(srt|ass|ssa|vtt)$/i.test(downloadUrl)) return { url: downloadUrl, extractedName: null };
445
- for (let attempt = 1; attempt <= 2; attempt++) {
446
- try {
447
- const buf = await getBinary(downloadUrl);
448
- if (!buf) continue;
449
- const zip = new AdmZip(buf);
450
- const best = pickFileFromZip(zip, lang, season, episode);
451
- if (!best) continue;
452
- const ext = best.entryName.split('.').pop().toLowerCase();
453
- const id = `s${++subSeq}`;
454
- subStore.set(id, { buffer: best.getData(), ext });
455
- const publicUrl = `${BASE_URL}/sub/${id}.${ext}`;
456
- console.log(`[ZIP] "${best.entryName}" β†’ /sub/${id}`);
457
- return { url: publicUrl, extractedName: best.entryName };
458
- } catch (e) { console.error('[ZIP] error:', e.message); }
459
- }
 
 
 
 
460
  return null;
461
  }
 
 
 
 
 
 
 
 
 
462
 
463
- /* ===================================================================
464
- EXPRESS ROUTES
465
- =================================================================== */
466
- const BASE_URL = process.env.SPACE_HOST ? `https://${process.env.SPACE_HOST}` : `http://localhost:${PORT}`;
467
-
468
- app.get('/manifest.json', (req, res) => res.json({
469
- id: 'org.van.vansubs.v4',
470
- name: 'VanSubs+ (Subf2m) – Smart Slug',
471
- description: 'Human-verified Arabic & English. AI-free. Candidate scoring for correct page.',
472
- version: '4.0.0',
473
- resources: ['subtitles'],
474
- types: ['movie', 'series'],
475
- idPrefixes: ['tt'],
476
- catalogs: []
477
  }));
478
 
479
- app.get('/sub/:id', (req, res) => {
480
- const id = req.params.id.replace(/\.[^.]+$/, '');
481
- const item = subStore.get(id);
482
- if (!item) return res.status(404).send('Not found');
483
- const mime = item.ext === 'vtt' ? 'text/vtt' : 'text/plain';
484
- res.setHeader('Content-Type', `${mime}; charset=utf-8`);
485
- res.setHeader('Content-Disposition', `inline; filename="sub.${item.ext}"`);
486
- res.send(item.buffer);
 
487
  });
488
 
489
  app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
@@ -491,73 +495,86 @@ app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
491
  const { type, id } = req.params;
492
  if (!id?.startsWith('tt')) return res.json({ subtitles: [] });
493
 
494
- const ck = `result:${id}`;
495
- const hit = cacheGet(ck);
496
  if (hit) return res.json({ subtitles: hit });
497
 
498
  try {
499
  let imdbId = id, season = null, episode = null;
500
  if (id.includes(':')) {
501
- const parts = id.split(':');
502
- imdbId = parts[0]; season = parts[1] ? +parts[1] : null; episode = parts[2] ? +parts[2] : null;
503
  }
504
  if (!/^tt\d+$/.test(imdbId)) return res.json({ subtitles: [] });
505
 
506
- const extra = req.params.extra ? decodeURIComponent(req.params.extra.replace('.json','')) : '';
507
- const streamTitle = (extra.match(/filename=([^&]+)/i) || [])[1] || '';
508
-
509
- console.log(`\n╔═ [Subf2m v4] ${type.toUpperCase()} ${imdbId}` +
510
- (season ? ` S${String(season).padStart(2,'0')}E${String(episode??0).padStart(2,'0')}` : '') +
511
- (streamTitle ? `\nβ•‘ hint: "${streamTitle}"` : ''));
512
-
513
- // TMDB metadata for candidate scoring
514
  const tmdb = await getTMDB(imdbId);
515
- const slug = await resolveBestSlug(imdbId, season, tmdb?.title, tmdb?.year);
516
- if (!slug) return res.json({ subtitles: [] });
 
517
 
518
- const [engSubs, araSubs] = await Promise.all([
519
  getSubsList(slug, 'english', season, episode),
520
- getSubsList(slug, 'arabic', season, episode)
521
  ]);
522
 
523
- const process = (list) => list
524
- .map(s => ({ ...s, score: scoreTrack(s, streamTitle) }))
525
- .sort((a, b) => b.score - a.score)
526
- .slice(0, MAX_PER_LANG);
527
-
528
- const engTop = process(engSubs);
529
- const araTop = process(araSubs);
530
-
531
- // Parallel ZIP resolution
532
- const resolveAll = async (arr) => {
533
- const results = await Promise.all(arr.map(async (s) => {
534
- const dl = `${s.detailUrl}/download`;
535
- const resolved = await resolveUrl(dl, s.lang, season, episode);
536
- if (!resolved) return null;
537
- return { ...s, url: resolved.url, filename: resolved.extractedName || s.releaseInfo };
538
- }));
539
- return results.filter(Boolean);
540
- };
541
-
542
- const engFinal = await resolveAll(engTop);
543
- const araFinal = await resolveAll(araTop);
544
 
545
- const proto = req.headers['x-forwarded-proto'] || (req.socket?.encrypted ? 'https' : 'http');
546
- const host = req.headers['x-forwarded-host'] || req.headers.host;
547
- const origin = `${proto}://${host}`;
548
 
549
- const subtitles = [
550
- ...engFinal.map(s => ({ id: `vsub-eng-${s.subId}`, url: s.url, lang: 'eng', name: `πŸ‡ΊπŸ‡Έ English ${s.isGood?'⭐':''} ${s.filename}`.substring(0,100) })),
551
- ...araFinal.map(s => ({ id: `vsub-ara-${s.subId}`, url: s.url, lang: 'ara', name: `πŸ‡ΈπŸ‡¦ Arabic ${s.isGood?'⭐':''} ${s.filename}`.substring(0,100) }))
552
- ];
 
 
553
 
 
 
554
  cacheSet(ck, subtitles);
555
- console.log(`β•‘ βœ… ${subtitles.length} tracks (EN:${engFinal.length} AR:${araFinal.length})`);
556
  res.json({ subtitles });
557
- } catch (e) {
558
- console.error(e);
559
- res.json({ subtitles: [] });
560
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
  });
562
 
563
- app.listen(PORT, '0.0.0.0', () => console.log(`\nβœ… Subf2m v4 – candidate scoring + episode parsing β†’ http://0.0.0.0:${PORT}`));
 
 
 
1
  const express = require('express');
2
  const cheerio = require('cheerio');
3
  const AdmZip = require('adm-zip');
 
10
  const PORT = process.env.PORT || 7860;
11
 
12
  app.use((req, res, next) => {
13
+ res.header('Access-Control-Allow-Origin', '*');
14
  res.header('Access-Control-Allow-Headers', '*');
15
  res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
16
  if (req.method === 'OPTIONS') return res.sendStatus(200);
17
  next();
18
  });
19
 
20
+ // ── CONFIG ──────────────────────────────────────────────────────────────────
 
 
21
  const BASE = 'https://subf2m.co';
22
+ const TMDB_KEY = '30166e2db6b420d3f230808cd1bb2c90';
23
  const TMDB_BASE = 'https://api.themoviedb.org/3';
24
  const MAX_PER_LANG = 4;
25
  const CACHE_TTL = 6 * 60 * 60 * 1000;
26
  const REQ_TIMEOUT = 15_000;
27
  const DL_TIMEOUT = 25_000;
28
+ const MAX_ENRICH = 30;
29
+
30
+ const ORDINALS = [
31
+ '', 'first', 'second', 'third', 'fourth', 'fifth',
32
+ 'sixth', 'seventh', 'eighth', 'ninth', 'tenth',
33
+ 'eleventh', 'twelfth', 'thirteenth', 'fourteenth', 'fifteenth'
34
+ ];
35
 
36
+ // Languages we ALLOW (everything else is silently skipped)
37
  const ALLOWED_LANGS = new Set(['english', 'arabic']);
38
 
39
+ // Other-language path fragments β€” used to drop mis-classified links
40
+ const OTHER_LANGS = [
41
+ 'french','spanish','portuguese','german','italian','turkish','persian','farsi',
42
+ 'hindi','russian','polish','dutch','swedish','norwegian','danish','finnish',
43
+ 'korean','japanese','chinese','malay','indonesian','vietnamese','thai','greek',
44
+ 'hungarian','czech','romanian','bulgarian','hebrew','ukrainian','serbian',
45
+ 'croatian','slovak','slovenian','latvian','estonian','lithuanian','albanian',
46
+ 'azerbaijani','bosnian','catalan','georgian','icelandic','macedonian','mongolian',
47
+ 'punjabi','sinhala','tamil','bengali','urdu','nepali','khmer','burmese',
48
+ 'afrikaans','swahili'
 
49
  ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
+ // ── STREAMING PLATFORM PRIORITY ──────────────────────────────────────────────
52
+ // Streaming-ripped subs are almost always perfectly synced retail encodes.
53
+ // Prioritise them over scene / BluRay rips which can be off by seconds.
54
+ const STREAMING_TIERS = [
55
+ { pts: 40, rx: [/\bnflx\b/i, /\bnetflix\b/i] },
56
+ { pts: 38, rx: [/\bamzn\b/i, /\bamazon\b/i, /\bprime\b/i] },
57
+ { pts: 36, rx: [/\bdsnp\b/i, /\bdisney\+?/i] },
58
+ { pts: 35, rx: [/\batvp\b/i, /\bappletv\b/i] },
59
+ { pts: 34, rx: [/\bhbo\b/i, /\bhbomax\b/i] },
60
+ { pts: 33, rx: [/\bpcok\b/i, /\bpeacock\b/i] },
61
+ { pts: 32, rx: [/\bhulu\b/i] },
62
+ { pts: 31, rx: [/\bpmtp\b/i, /\bparamount/i] },
63
+ { pts: 28, rx: [/\bwavve\b/i, /\btving\b/i, /\bwatcha\b/i, /\bviafree\b/i] },
64
+ { pts: 20, rx: [/\bweb[- ]?dl\b/i, /\bwebrip\b/i] },
65
+ { pts: 14, rx: [/\bbluray\b/i, /\bblu[- ]ray\b/i, /\bbdrip\b/i, /\b1080p\b/i, /\b4k\b/i] },
66
+ { pts: 12, rx: [/\bhdtv\b/i, /\b720p\b/i] },
67
+ { pts: 8, rx: [/\bproper\b/i, /\brepack\b/i, /\bsynced\b/i, /\bsync\b/i, /\bfixed\b/i] },
68
+ ];
69
+
70
+ function streamScore(text) {
71
+ if (!text) return 0;
72
+ let best = 0;
73
+ for (const t of STREAMING_TIERS)
74
+ if (t.rx.some(r => r.test(text))) best = Math.max(best, t.pts);
75
+ return best;
76
+ }
77
+
78
+ // ── AI SUBTITLE DETECTION ────────────────────────────────────────────────────
79
+ const AI_QUICK = [
80
+ 'a.i','chatgpt','deepl','whisper','openai','gemini','claude','llama',
81
+ 'machine translat','auto translat','auto-translat','auto generat','autosub',
82
+ 'neural','yandex','bing translat','google translat','amazon translat',
83
+ 'microsoft translat','nmt','artificial intel',' ai sub','ai-sub',
84
+ 'ai translat','translated by ai','generated by ai',
85
+ ];
86
+ const AI_RX = [
87
+ /\ba\.i\.\b/i, /\bartificial[\s\-_]intelligence/i,
88
+ /\b(ai|a\.i)[\s\-_](sub(title)?s?|translat|generat|correct|made|creat|caption|dub|edit|sync|fix)/i,
89
+ /(sub(title)?s?|translat|generat|creat|made|dub|caption|correct)\s+(by\s+)?(ai|a\.i)\b/i,
90
+ /\b(ai|a\.i)[- _]?(powered|assisted|based|driven|enabled)\b/i,
91
+ /machine[\s\-_]?translat/i, /auto[\s\-_]?translat/i, /auto[\s\-_]?sub(title)?s?\b/i,
92
+ /\bautosub\b/i, /\bnmt\b/, /neural[\s\-_]?(machine[\s\-_]?)?translat/i,
93
+ /\bwhisper[\s\-_]?(cpp|x|large|medium|small|tiny|base|v[123])?\b/i,
94
+ /\bfaster[\s\-_]?whisper\b/i, /\bwhisperx\b/i,
95
+ /\bopenai\b/i, /\bchatgpt\b/i,
96
+ /\bgpt[\s\-_]?[1-9o]?[\s\-_]?(turbo|mini|nano|ultra|pro|plus|omni|preview|vision)?\b/i,
97
+ /\banthropic\b/i, /\bclaude[\s\-_]?(2|3|4|sonnet|haiku|opus|instant|v\d)?\b/i,
98
+ /\bgemini[\s\-_]?(pro|ultra|flash|nano|advanced|1\.5|2\.0)?\b/i,
99
+ /\bgoogle[\s\-_]?(bard|translat|speech|asr)\b/i, /\bbard\b/i,
100
+ /\bllama[\s\-_]?[23]?\b/i, /\bmeta[\s\-_]?ai\b/i,
101
+ /\bdeepseek[\s\-_]?(v[123]|r1)?\b/i, /\bdeep[\s\-_]?l\b/i,
102
+ /\bmistral\b/i, /\bmixtral\b/i, /\bgrok[\s\-_]?[12]?\b/i,
103
+ /\bbing[\s\-_]?translat\b/i, /\bcopilot\b/i,
104
+ /\byandex[\s\-_]?(translat|gpt)\b/i, /\bamazon[\s\-_]?(translat|titan)\b/i,
105
+ /\bvosk\b/i, /\bassemblyai\b/i, /\bspeech[\s\-_]?(recogni|to[\s\-_]text)/i,
106
+ /\bsubtitlebee\b/i, /\bmaestra\b/i, /\bzubtitle\b/i, /\bsubmagic\b/i,
107
+ /\bhappy[\s\-_]?scribe\b/i, /\bdescript\b/i, /\bkapwing\b/i,
108
+ /\bveed[\s\-_]?io\b/i, /\bsubanana\b/i,
109
+ ];
110
+ function isAI(text) {
111
+ if (!text) return false;
112
+ const lo = text.toLowerCase();
113
+ if (!AI_QUICK.some(q => lo.includes(q)) && !lo.includes(' ai') && !lo.includes('ai ') && !lo.includes('ai-')) return false;
114
+ return AI_RX.some(r => r.test(text));
115
+ }
116
+
117
+ // ── HTTP CORE ────────────────────────────────────────────────────────────────
118
  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';
119
 
120
+ function _req(urlStr, opts = {}) {
121
  return new Promise((resolve, reject) => {
122
+ const parsed = new URL(urlStr);
123
+ const lib = parsed.protocol === 'https:' ? https : http;
124
+ const ro = {
 
125
  hostname: parsed.hostname,
126
  port : parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
127
  path : parsed.pathname + parsed.search,
128
  method : 'GET',
129
  headers : {
130
+ 'User-Agent' : UA,
131
+ 'Accept' : opts.binary ? 'application/zip,*/*' : 'text/html,*/*;q=0.8',
132
+ 'Accept-Language' : 'en-US,en;q=0.9',
133
+ 'Accept-Encoding' : 'gzip, deflate',
134
+ 'Referer' : BASE + '/',
135
+ 'Connection' : 'keep-alive',
 
136
  ...(opts.headers || {})
137
  }
138
  };
139
+ const req = lib.request(ro, res => {
140
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
141
+ return _req(new URL(res.headers.location, urlStr).toString(), opts).then(resolve).catch(reject);
 
142
  }
143
  if (res.statusCode === 404) return resolve(null);
144
  if (res.statusCode !== 200) return resolve(null);
145
  const chunks = [];
146
+ let stream = res;
147
  const enc = res.headers['content-encoding'] || '';
148
  if (enc.includes('gzip')) stream = res.pipe(zlib.createGunzip());
149
  else if (enc.includes('br')) stream = res.pipe(zlib.createBrotliDecompress());
 
152
  stream.on('end', () => resolve(opts.binary ? Buffer.concat(chunks) : Buffer.concat(chunks).toString('utf8')));
153
  stream.on('error', reject);
154
  });
155
+ req.setTimeout(opts.timeout || REQ_TIMEOUT, () => { req.destroy(); reject(new Error('timeout')); });
156
  req.on('error', reject);
157
  req.end();
158
  });
159
  }
 
160
  async function getHTML(url, tries = 3) {
161
  for (let i = 0; i < tries; i++) {
162
+ try { const r = await _req(url); if (r !== null) return r; }
163
+ catch (e) { if (i < tries - 1) await sleep(1200 * (i + 1)); }
164
  }
165
  return null;
166
  }
167
+ async function getBin(url, tries = 3) {
 
168
  for (let i = 0; i < tries; i++) {
169
+ try { const r = await _req(url, { binary: true, timeout: DL_TIMEOUT }); if (r !== null) return r; }
170
+ catch (e) { if (i < tries - 1) await sleep(1200 * (i + 1)); }
171
  }
172
  return null;
173
  }
174
 
175
+ // ── CACHE ────────────────────────────────────────────────────────────────────
176
+ const _cache = new Map();
177
+ function cacheGet(k) {
178
+ const item = _cache.get(k);
 
179
  if (!item) return null;
180
+ if (Date.now() - item.ts > CACHE_TTL) { _cache.delete(k); return null; }
181
  return item.val;
182
  }
183
+ function cacheSet(k, v) {
184
+ if (_cache.size >= 800) { let n = 0; for (const key of _cache.keys()) { _cache.delete(key); if (++n >= 80) break; } }
185
+ _cache.set(k, { val: v, ts: Date.now() });
186
  }
187
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
188
+ const _clean = t => (t || '').replace(/\s+/g, ' ').trim();
189
 
190
+ // ── TMDB ─────────────────────────────────────────────────────────────────────
 
 
191
  async function getTMDB(imdbId) {
192
+ const ck = `tmdb:${imdbId}`, hit = cacheGet(ck);
 
193
  if (hit !== null) return hit;
194
  try {
195
+ const raw = await _req(`${TMDB_BASE}/find/${imdbId}?external_source=imdb_id&api_key=${TMDB_KEY}`, { timeout: 8000 });
196
  if (!raw) { cacheSet(ck, null); return null; }
197
  const d = JSON.parse(raw);
198
  let info = null;
199
  if (d.movie_results?.length) {
200
  const m = d.movie_results[0];
201
+ info = { title: m.title || m.original_title, year: m.release_date?.slice(0,4) || null, tmdbId: m.id, type: 'movie' };
202
  } else if (d.tv_results?.length) {
203
  const t = d.tv_results[0];
204
+ info = { title: t.name || t.original_name, year: t.first_air_date?.slice(0,4) || null, tmdbId: t.id, type: 'series' };
205
  }
206
+ cacheSet(ck, info); return info;
 
207
  } catch { cacheSet(ck, null); return null; }
208
  }
209
 
210
+ // ── SLUG RESOLUTION: IMDb ID β†’ subf2m collection slug ────────────────────────
211
+ // Flow confirmed by Bazarr/community research:
212
+ // subf2m.co/subtitles/searchbytitle?query=tt{id}&l=
213
+ // returns results whose <a> text is "Show Name - First Season (2017)"
214
+ // We pick the entry whose text matches the target season (or movie year).
215
+ async function resolveSlug(imdbId, type, season, tmdb) {
216
+ const ck = `f2mslug:${imdbId}:${season || 0}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  const hit = cacheGet(ck);
218
  if (hit !== null) return hit;
219
 
220
+ // Pass 1: search by IMDb ID (most reliable β€” works for all content globally)
221
+ let slug = await _f2mSearch(imdbId, type, season, tmdb);
222
+ // Pass 2: search by English title (fallback for obscure content)
223
+ if (!slug && tmdb?.title) slug = await _f2mSearch(tmdb.title, type, season, tmdb);
224
+
225
+ cacheSet(ck, slug);
226
+ return slug;
227
+ }
228
+
229
+ async function _f2mSearch(query, type, season, tmdb) {
230
+ const url = `${BASE}/subtitles/searchbytitle?query=${encodeURIComponent(query)}&l=`;
231
  const html = await getHTML(url);
232
+ if (!html) return null;
233
 
234
  const $ = cheerio.load(html);
235
+ const results = [];
236
+ const seen = new Set();
237
 
238
+ $('a[href]').each((_, el) => {
239
+ const href = ($(el).attr('href') || '').trim();
240
+ if (!href.startsWith('/subtitles/') || href.includes('searchbytitle')) return;
241
  const parts = href.split('/').filter(Boolean);
242
  if (parts.length < 2) return;
243
  const slug = parts[1];
244
+ if (!slug || seen.has(slug)) return;
245
+ seen.add(slug);
246
+ const text = ($(el).text() || '').replace(/\s+/g, ' ').trim().toLowerCase();
247
+ if (!text) return;
248
+ results.push({ slug, text });
249
  });
250
 
251
+ if (!results.length) return null;
252
+ return type === 'series' && season
253
+ ? _pickSeries(results, tmdb, season)
254
+ : _pickMovie(results, tmdb);
255
+ }
256
+
257
+ function _pickSeries(results, tmdb, season) {
258
+ const ord = (ORDINALS[season] || '').toLowerCase();
259
+ const s2 = String(season).padStart(2, '0');
260
+ const year = tmdb?.year || '';
261
+ // First word of the show's slug β€” used as a guard against unrelated results
262
+ const guard = tmdb?.title
263
+ ? tmdb.title.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'')
264
+ .replace(/[^a-z0-9]/g, ' ').trim().split(' ')[0]
265
+ : '';
266
+
267
+ let best = null, bestScore = -1;
268
+
269
+ for (const r of results) {
270
+ const t = r.text;
271
+ const s = r.slug.toLowerCase();
272
+ let score = 0;
273
+
274
+ // Guard: result must at least start with the show's first word
275
+ if (guard && !s.startsWith(guard) && !t.startsWith(guard)) continue;
276
+
277
+ // Ordinal word match (strongest: "first season", "second season")
278
+ if (ord) {
279
+ if (t.includes(`${ord} season`) || t.includes(`${ord} series`)) score += 100;
280
+ if (s.includes(`-${ord}-season`) || s.includes(`${ord}-season`)) score += 90;
 
 
281
  }
282
+ // Numeric season
283
+ if (t.includes(`season ${season}`) || s.includes(`season-${season}`)) score += 80;
284
+ if (s.includes(`-s${s2}`)) score += 75;
285
+ // Year
286
+ if (year && (t.includes(`(${year})`) || s.includes(year))) score += 20;
287
+ // Has "season" keyword at all
288
+ if (t.includes('season') || s.includes('season')) score += 5;
289
+
290
+ if (score > bestScore) { bestScore = score; best = r; }
291
  }
292
 
293
+ // Must have at least a "season" keyword β€” avoids picking unrelated movie entries
294
+ if (best && bestScore >= 5) {
295
+ console.log(`[f2m-slug] S${season} β†’ "${best.slug}" (score ${bestScore})`);
296
+ return best.slug;
297
+ }
298
+ // Absolute fallback: first result whose slug starts with guard word
299
+ const fb = guard ? results.find(r => r.slug.toLowerCase().startsWith(guard)) : null;
300
+ if (fb) console.log(`[f2m-slug] S${season} fallback β†’ "${fb.slug}"`);
301
+ return fb ? fb.slug : null;
 
 
 
 
 
 
 
 
 
 
 
302
  }
303
 
304
+ function _pickMovie(results, tmdb) {
305
+ if (!results.length) return null;
306
+ const year = tmdb?.year || '';
307
+ const guard = tmdb?.title
308
+ ? tmdb.title.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'')
309
+ .replace(/[^a-z0-9]/g, ' ').trim().split(' ')[0]
310
+ : '';
311
+
312
+ let best = null, bestScore = -1;
313
+ for (const r of results) {
314
+ const t = r.text;
315
+ const s = r.slug.toLowerCase();
316
+ let score = 0;
317
+
318
+ // Penalise series entries hard
319
+ if (t.includes('season') || s.includes('season')) { score -= 100; }
320
+ if (guard && s.startsWith(guard)) score += 40;
321
+ if (year && (t.includes(`(${year})`) || s.includes(year))) score += 50;
322
+
323
+ if (score > bestScore) { bestScore = score; best = r; }
324
+ }
325
+ if (best) {
326
+ console.log(`[f2m-slug] movie β†’ "${best.slug}" (score ${bestScore})`);
327
+ return best.slug;
328
+ }
329
+ return results[0].slug;
 
 
 
 
 
330
  }
331
 
332
+ // ── SUBTITLE LIST ─────────────────────────────────────────────────────────────
333
  async function getSubsList(slug, lang, season, episode) {
334
  if (!ALLOWED_LANGS.has(lang)) return [];
335
+ const ck = `f2mlist:${slug}:${lang}:${episode || 0}`;
 
336
  const hit = cacheGet(ck);
337
  if (hit) return hit;
338
 
339
+ const html = await getHTML(`${BASE}/subtitles/${slug}/${lang}`);
 
340
  if (!html) { cacheSet(ck, []); return []; }
341
 
342
  const $ = cheerio.load(html);
343
  let raw = [];
344
+ $('li.item').each((_, el) => _extractItem($, el, lang, raw));
345
 
346
+ // Fallback scraper for when li.item isn't populated
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  if (!raw.length) {
348
  $('a[href]').each((_, a) => {
349
  const href = $(a).attr('href') || '';
 
351
  const parts = href.split('/');
352
  const subId = parts[parts.length - 1];
353
  if (!subId || !/^\d+$/.test(subId)) return;
354
+ raw.push({ subId, lang, detailUrl: href.startsWith('http') ? href : `${BASE}${href}`,
355
+ isGood: false, releaseInfo: '', comment: '', authorNote: '', goodVotes: 0, badVotes: 0, _rawText: '' });
 
356
  });
357
  }
358
 
359
+ // Episode filter for series
360
+ if (season != null && episode != null) {
361
+ const s2 = String(season).padStart(2, '0');
362
+ const ep2 = String(episode).padStart(2, '0');
363
+ const pats = [`s${s2}e${ep2}`, `${season}x${ep2}`, ` e${ep2}`, `ep${ep2}`, `episode ${episode}`, `- ${episode} -`];
364
+ const filtered = raw.filter(it => {
365
+ const txt = (it._rawText || it.releaseInfo || '').toLowerCase();
366
+ return pats.some(p => txt.includes(p));
367
  });
368
+ if (filtered.length) {
369
+ raw = filtered;
370
+ console.log(`[f2m-ep] S${season}E${episode}: filtered to ${raw.length}`);
371
+ } else {
372
+ console.log(`[f2m-ep] S${season}E${episode}: no exact match, keeping all ${raw.length}`);
373
+ }
374
  }
375
 
376
+ const seen = new Set();
377
+ const unique = raw.filter(s => { if (seen.has(s.subId)) return false; seen.add(s.subId); return true; });
378
 
379
+ // Enrich detail pages in parallel (batched to avoid hammering the server)
380
+ const toEnrich = unique.slice(0, MAX_ENRICH);
381
+ const enriched = (await Promise.all(toEnrich.map(s => _enrich(s)))).filter(Boolean);
382
+ const human = enriched.filter(s => !isAI(`${s.releaseInfo} ${s.comment} ${s.authorNote}`));
383
 
384
+ // Sort: streaming platform first, then isGood, then raw quality
385
+ human.sort((a, b) => {
386
+ const sa = streamScore(a.releaseInfo), sb = streamScore(b.releaseInfo);
387
+ if (sa !== sb) return sb - sa;
388
+ const ga = a.isGood ? 1 : 0, gb = b.isGood ? 1 : 0;
389
+ return gb - ga;
390
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
+ cacheSet(ck, human);
393
+ return human;
394
  }
395
 
396
+ function _extractItem($, el, lang, out) {
397
+ const $el = $(el);
398
+ const rawText = _clean($el.text());
399
+ let detailHref = null;
400
+ $el.find('a').each((_, a) => {
401
+ const h = $(a).attr('href') || '';
402
+ if (h.includes(`/${lang}/`) && /\/\d+$/.test(h)) detailHref = h;
403
+ });
404
+ if (!detailHref) return;
405
+ if (OTHER_LANGS.some(ol => detailHref.toLowerCase().includes(`/${ol}/`))) return;
406
+ const parts = detailHref.split('/');
407
+ const subId = parts[parts.length - 1];
408
+ if (!subId || !/^\d+$/.test(subId)) return;
409
+ const isGood = $el.find('.green-icon,.positive-icon').length > 0 ||
410
+ $el.find('img[alt="good"],img[alt="Positive"],img[alt="positive"]').length > 0 ||
411
+ ($el.attr('class') || '').toLowerCase().includes('good');
412
+ const comment = _clean($el.find('p.comment,.comment').first().text());
413
+ const relLines = $el.find('.r li,ul.release li,ul.scrolllist li,ul li')
414
+ .map((_, li) => $(li).text().trim()).get().filter(Boolean);
415
+ const releaseInfo = (relLines.join(' | ') || _clean($el.text())).substring(0, 130);
416
+ if (isAI(`${comment} ${releaseInfo}`)) return;
417
+ out.push({ subId, lang, isGood, releaseInfo, comment, authorNote: '', goodVotes: 0, badVotes: 0,
418
+ detailUrl: detailHref.startsWith('http') ? detailHref : `${BASE}${detailHref}`, _rawText: rawText });
419
+ }
420
 
421
+ async function _enrich(sub) {
422
+ const ck = `f2mdet:${sub.subId}`, hit = cacheGet(ck);
423
+ if (hit !== null) { if (hit.__blocked) return null; return { ...sub, ...hit }; }
424
+ const html = await getHTML(sub.detailUrl);
425
+ if (!html) return sub;
426
+ const $ = cheerio.load(html);
427
+ const txt = $.root().text();
428
+ const goodVotes = +(txt.match(/voted\s+as\s+good\s+by[:\s]*(\d+)/i)?.[1] ?? 0);
429
+ const badVotes = +(txt.match(/voted\s+as\s+bad\s+by[:\s]*(\d+)/i)?.[1] ?? 0);
430
+ const total = goodVotes + badVotes;
431
+ const ratio = total >= 3 ? goodVotes / total : null;
432
+ const isGood = sub.isGood || (ratio !== null && ratio >= 0.70 && goodVotes >= 2);
433
+ const authorNote = _clean($('p.comment,.comment,.notes,.author-comment').first().text());
434
+ if (isAI(`${authorNote} ${sub.releaseInfo} ${sub.comment}`)) { cacheSet(ck, { __blocked: true }); return null; }
435
+ const relLines = $('ul.release li,.release-names li,ul.scrolllist li').map((_, e) => $(e).text().trim()).get();
436
+ const releaseInfo = relLines.length ? relLines.join(' | ').substring(0, 130) : sub.releaseInfo;
437
+ const result = { releaseInfo, goodVotes, badVotes, isGood, authorNote };
438
+ cacheSet(ck, result);
439
+ return { ...sub, ...result };
440
  }
441
 
442
+ // ── SRT EXTRACTION ───────────────────────────────────────────────────────────
443
+ async function fetchSRT(dlUrl) {
444
+ const ck = `f2msrt:${dlUrl}`, hit = cacheGet(ck);
445
+ if (hit !== null) return hit;
446
+ try {
447
+ const buf = await getBin(dlUrl);
448
+ if (!buf) return null;
449
+ const content = _fromZip(buf) ?? _direct(buf);
450
+ if (!content) return null;
451
+ cacheSet(ck, content); return content;
452
+ } catch { return null; }
453
+ }
454
+ function _fromZip(buf) {
455
+ try {
456
+ const RANK = { '.srt': 0, '.vtt': 1, '.ass': 2, '.ssa': 3 };
457
+ const zip = new AdmZip(buf);
458
+ const files = zip.getEntries()
459
+ .filter(e => { const n = e.entryName.toLowerCase(); return !e.isDirectory && !n.startsWith('__macosx') && Object.keys(RANK).some(x => n.endsWith(x)); })
460
+ .sort((a, b) => (RANK[_ext(a.entryName)] ?? 9) - (RANK[_ext(b.entryName)] ?? 9));
461
+ for (const f of files) { const t = _decode(zip.readFile(f)); if (_isSub(t)) return t; }
462
+ } catch {}
463
  return null;
464
  }
465
+ function _direct(buf) { const t = _decode(buf); return _isSub(t) ? t : null; }
466
+ const _ext = n => (n.match(/(\.[^.]+)$/)?.[1]?.toLowerCase() ?? '');
467
+ const _isSub = t => typeof t === 'string' && t.includes('-->') && t.length > 50;
468
+ function _decode(buf) {
469
+ if (!buf) return '';
470
+ const hasBOM = buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF;
471
+ const u8 = buf.slice(hasBOM ? 3 : 0).toString('utf8');
472
+ return _isSub(u8) ? u8 : buf.toString('latin1');
473
+ }
474
 
475
+ // ── ROUTES ───────────────────────────────────────────────────────────────────
476
+ app.get('/manifest.json', (_, res) => res.json({
477
+ id: 'org.van.vanstreams.plus.primary', name: 'VanStreams+',
478
+ description: 'Human-Verified Arabic & English subtitles. Anti-AI.',
479
+ version: '2.0.0', resources: ['subtitles'], types: ['movie','series'], idPrefixes: ['tt'], catalogs: []
 
 
 
 
 
 
 
 
 
480
  }));
481
 
482
+ app.get('/sub/:enc.srt', async (req, res) => {
483
+ let dlUrl = '';
484
+ try { dlUrl = Buffer.from(req.params.enc, 'base64url').toString('utf8'); } catch { return res.status(400).end(); }
485
+ if (!dlUrl.startsWith('https://subf2m.co/') || !dlUrl.endsWith('/download')) return res.status(403).end();
486
+ const content = await fetchSRT(dlUrl);
487
+ if (!content) return res.status(404).end();
488
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
489
+ res.setHeader('Cache-Control', 'public, max-age=86400');
490
+ res.end(content);
491
  });
492
 
493
  app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
 
495
  const { type, id } = req.params;
496
  if (!id?.startsWith('tt')) return res.json({ subtitles: [] });
497
 
498
+ const ck = `f2mresult:${id}`, hit = cacheGet(ck);
 
499
  if (hit) return res.json({ subtitles: hit });
500
 
501
  try {
502
  let imdbId = id, season = null, episode = null;
503
  if (id.includes(':')) {
504
+ const p = id.split(':');
505
+ imdbId = p[0]; season = p[1] ? +p[1] : null; episode = p[2] ? +p[2] : null;
506
  }
507
  if (!/^tt\d+$/.test(imdbId)) return res.json({ subtitles: [] });
508
 
 
 
 
 
 
 
 
 
509
  const tmdb = await getTMDB(imdbId);
510
+ const slug = await resolveSlug(imdbId, type, season, tmdb);
511
+ if (!slug) { console.log(`[f2m] no slug for ${imdbId}`); return res.json({ subtitles: [] }); }
512
+ console.log(`[f2m] ${type} ${imdbId} S${season}E${episode} β†’ ${slug}`);
513
 
514
+ const [engRes, araRes] = await Promise.allSettled([
515
  getSubsList(slug, 'english', season, episode),
516
+ getSubsList(slug, 'arabic', season, episode)
517
  ]);
518
 
519
+ const engSubs = (engRes.status === 'fulfilled' ? engRes.value : []).slice(0, MAX_PER_LANG);
520
+ const araSubs = (araRes.status === 'fulfilled' ? araRes.value : []).slice(0, MAX_PER_LANG);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
 
522
+ const proto = req.headers['x-forwarded-proto'] || (req.socket?.encrypted ? 'https' : 'http');
523
+ const origin = `${proto}://${req.headers['x-forwarded-host'] || req.headers.host}`;
 
524
 
525
+ const build = (subs, code, flag) => subs.map(s => {
526
+ const enc = Buffer.from(`${s.detailUrl}/download`).toString('base64url');
527
+ const star = s.isGood ? ' ⭐' : '';
528
+ const name = `${flag}${star} ${s.releaseInfo}`.trim().substring(0, 100);
529
+ return { id: `vsub-${code}-${s.subId}`, url: `${origin}/sub/${enc}.srt`, lang: code, name };
530
+ });
531
 
532
+ // Arabic first β†’ Stremio auto-selects [0] as default
533
+ const subtitles = [...build(araSubs, 'ara', 'πŸ‡ΈπŸ‡¦ Arabic'), ...build(engSubs, 'eng', 'πŸ‡ΊπŸ‡Έ English')];
534
  cacheSet(ck, subtitles);
 
535
  res.json({ subtitles });
536
+ } catch (e) { console.error('[f2m route]', e); res.json({ subtitles: [] }); }
537
+ });
538
+
539
+ app.get('/', (req, res) => {
540
+ const manifestUrl = `${req.protocol}://${req.get('host')}/manifest.json`;
541
+ res.type('html').send(`<!DOCTYPE html>
542
+ <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
543
+ <title>VanStreams+ SubF2M</title>
544
+ <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
545
+ 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(0,180,255,.07) 0%,transparent 70%)}
546
+ .wrap{width:100%;max-width:560px;display:flex;flex-direction:column;gap:20px}
547
+ .logo{font-size:2.6rem;font-weight:800;letter-spacing:-1px}.logo .van{color:#00c6ff}.logo .subs{color:#fff}
548
+ .badge-row{display:flex;gap:8px;flex-wrap:wrap}
549
+ .badge{display:inline-flex;align-items:center;gap:5px;background:rgba(0,198,255,.08);border:1px solid rgba(0,198,255,.18);color:#fff;padding:4px 11px;border-radius:100px;font-size:.72rem;font-weight:700;letter-spacing:.4px;text-transform:uppercase}
550
+ .badge.green{background:rgba(0,220,130,.08);border-color:rgba(0,220,130,.18);color:#00dc82}
551
+ .desc{color:#fff;font-size:.9rem;line-height:1.6;margin-top:2px}
552
+ .card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:16px;padding:20px 22px}
553
+ .card-label{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#3a5060;margin-bottom:10px}
554
+ .url-box{font-family:'SF Mono','Fira Code',monospace;font-size:.8rem;color:#fff;background:rgba(0,0,0,.25);border:1px solid rgba(0,198,255,.12);border-radius:10px;padding:11px 14px;word-break:break-all;line-height:1.5}
555
+ .actions{display:flex;gap:10px;margin-top:14px}
556
+ .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}
557
+ .btn-primary{background:#00c6ff;color:#050810}.btn-primary:hover{background:#00b0e8}
558
+ .btn-ghost{background:rgba(255,255,255,.05);color:#7a8fa3;border:1px solid rgba(255,255,255,.09)}.btn-ghost:hover{background:rgba(255,255,255,.09);color:#aabdcc}
559
+ .stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
560
+ .stat{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.06);border-radius:12px;padding:16px 12px;text-align:center}
561
+ .stat-val{font-size:1.5rem;font-weight:800;color:#fff;line-height:1;margin-bottom:5px}
562
+ .stat-lbl{font-size:.68rem;color:#3a5060;font-weight:600;text-transform:uppercase;letter-spacing:.5px}</style></head>
563
+ <body><div class="wrap">
564
+ <div><div class="logo"><span class="van">Van</span> <span class="subs">Subs+</span></div>
565
+ <div class="badge-row" style="margin-top:12px"><span class="badge">πŸ›‘ Anti-AI</span><span class="badge green">βœ” Human-Verified</span><span class="badge">πŸ“‘ SubF2M</span></div>
566
+ <p class="desc" style="margin-top:12px">Human-Verified Arabic &amp; English Subtitles. Anti-AI.<br>IMDb-native search Β· streaming platform priority Β· Arabic first.</p></div>
567
+ <div class="card"><div class="card-label">Stremio Install URL</div>
568
+ <div class="url-box">${manifestUrl}</div>
569
+ <div class="actions">
570
+ <a href="/manifest.json" class="btn btn-primary">View Manifest</a>
571
+ <button class="btn btn-ghost" onclick="navigator.clipboard.writeText('${manifestUrl}').then(()=>{this.textContent='βœ” Copied';setTimeout(()=>this.textContent='Copy URL',2000)})">Copy URL</button>
572
+ </div></div>
573
+ <div class="stats">
574
+ <div class="stat"><div class="stat-val">2</div><div class="stat-lbl">Languages</div></div>
575
+ <div class="stat"><div class="stat-val">4</div><div class="stat-lbl">Max per lang</div></div>
576
+ <div class="stat"><div class="stat-val">0</div><div class="stat-lbl">AI subs</div></div>
577
+ </div></div></body></html>`);
578
  });
579
 
580
+ app.listen(PORT, '0.0.0.0', () => console.log(`βœ… VanStreams+ SubF2M v2.0.0 β†’ http://0.0.0.0:${PORT}`));