vanseleem commited on
Commit
a192568
ยท
verified ยท
1 Parent(s): a070cfa

Update index.js

Browse files
Files changed (1) hide show
  1. index.js +455 -385
index.js CHANGED
@@ -1,5 +1,4 @@
1
  'use strict';
2
-
3
  const express = require('express');
4
  const cheerio = require('cheerio');
5
  const AdmZip = require('adm-zip');
@@ -19,61 +18,53 @@ app.use((req, res, next) => {
19
  next();
20
  });
21
 
22
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
23
- // โ–ˆโ–ˆ PROXY CONFIG โ€” edit these 3 URLs at any time
24
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
25
- const PROXY_1 = (dlUrl) => `https://corsproxy.io/?url=${encodeURIComponent(dlUrl)}`;
26
- const PROXY_2 = (dlUrl) => `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(dlUrl)}`;
27
- const PROXY_3 = (dlUrl) => `https://thingproxy.freeboard.io/fetch/${dlUrl}`;
 
28
 
29
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
30
- // โ–ˆโ–ˆ CORE CONFIG
31
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
32
  const BASE = 'https://subf2m.co';
33
- const TMDB_KEY = '30166e2db6b420d3f230808cd1bb2c90';
34
  const TMDB_BASE = 'https://api.themoviedb.org/3';
35
  const MAX_PER_LANG = 4;
36
- const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 h
37
- const REQ_TIMEOUT = 15_000;
38
- const DL_TIMEOUT = 25_000;
39
  const MAX_DETAIL = 30;
40
 
41
- // Ordinal words used in subf2m season slugs
42
  const ORDINALS = [
43
  '', 'first', 'second', 'third', 'fourth', 'fifth',
44
  'sixth', 'seventh', 'eighth', 'ninth', 'tenth',
45
  'eleventh', 'twelfth', 'thirteenth', 'fourteenth', 'fifteenth',
46
- 'sixteenth', 'seventeenth', 'eighteenth', 'nineteenth', 'twentieth'
 
 
47
  ];
48
 
49
  const ALLOWED_LANGS = new Set(['english', 'arabic']);
50
 
51
- // Languages we explicitly block at extraction
52
  const OTHER_LANGS = [
53
- 'french','spanish','portuguese','german','italian','turkish',
54
- 'persian','farsi','hindi','russian','polish','dutch','swedish',
55
- 'norwegian','danish','finnish','korean','japanese','chinese',
56
- 'malay','indonesian','vietnamese','thai','greek','hungarian',
57
- 'czech','romanian','bulgarian','hebrew','ukrainian','serbian',
58
- 'croatian','slovak','slovenian','latvian','estonian','lithuanian',
59
- 'albanian','azerbaijani','bosnian','catalan','georgian','icelandic',
60
- 'macedonian','mongolian','punjabi','sinhala','tamil','bengali',
61
- 'urdu','nepali','khmer','burmese','afrikaans','swahili','brazilian'
62
  ];
63
 
64
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
65
- // โ–ˆโ–ˆ QUALITY SCORING
66
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
67
  const QUALITY_TIERS = [
68
  { bonus: 0.13, terms: [/\bbluray\b/i,/\bblu[- ]ray\b/i,/\bbdrip\b/i,/\bbdremux\b/i,/\b4k\b/i,/\b2160p\b/i,/\b1080p\b/i] },
69
  { bonus: 0.10, terms: [/\bweb[- ]?dl\b/i,/\bwebrip\b/i,/\bamzn\b/i,/\bnflx\b/i,/\bhbo\b/i,/\bdsnp\b/i,/\bappletv\b/i,/\batvp\b/i,/\bpcok\b/i] },
70
  { bonus: 0.05, terms: [/\bhdtv\b/i,/\b720p\b/i,/\bhdrip\b/i] },
71
- { bonus: 0.08, terms: [/\bproper\b/i,/\brepack\b/i,/\bsynced\b/i,/\bsync\b/i,/\bfixed\b/i,/\bcorrected\b/i,/\bv2\b/i,/\bv3\b/i] },
72
  ];
73
 
74
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
75
- // โ–ˆโ–ˆ AI-DETECTION
76
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
77
  const AI_PATTERNS = [
78
  /\ba\.i\.\b/i,/\bai[\s\-_]sub(title)?s?\b/i,/\bai[\s\-_](translat|generat|correct|made|creat)/i,
79
  /(translat|generat|creat|made|sub(title)?s?)\s+(by\s+)?ai\b/i,/machine[\s\-_]translat/i,
@@ -84,25 +75,27 @@ const AI_PATTERNS = [
84
  /\bllama\b/i,/neural[\s\-_](translat|sub|net|machine)/i,/\bnmt\b/i,
85
  /artificial[\s\-_]intelligence/i,/computer[\s\-_](translat|generat)/i,
86
  ];
 
87
  const AI_QUICK_HITS = [
88
  'a.i','chatgpt','deepl','whisper','openai','gemini','claude','llama',
89
  'machine translat','auto translat','auto-translat','auto generat','autosub','neural',
90
  'yandex','bing translat','google translat','amazon translat','microsoft translat','nmt',
91
  'artificial intel',' ai sub','ai-sub','ai translat','translated by ai','generated by ai',
92
  ];
 
93
  function isAISub(text) {
94
  if (!text) return false;
95
  const lo = text.toLowerCase();
96
- const maybeAI = AI_QUICK_HITS.some(q => lo.includes(q)) || lo.includes(' ai') || lo.includes('ai ') || lo.includes('ai-');
97
  if (!maybeAI) return false;
98
  return AI_PATTERNS.some(rx => rx.test(text));
99
  }
100
 
101
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
102
- // โ–ˆโ–ˆ HTTP HELPERS
103
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
104
  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';
105
 
 
 
 
106
  function _request(urlStr, opts = {}) {
107
  return new Promise((resolve, reject) => {
108
  const parsed = new URL(urlStr);
@@ -115,17 +108,15 @@ function _request(urlStr, opts = {}) {
115
  method : 'GET',
116
  headers : {
117
  'User-Agent' : UA,
118
- 'Accept' : opts.binary
119
- ? 'application/zip,application/octet-stream,*/*'
120
- : 'text/html,application/xhtml+xml,*/*;q=0.8',
121
  'Accept-Language' : 'en-US,en;q=0.9',
122
  'Accept-Encoding' : 'gzip, deflate',
123
  'Referer' : 'https://subf2m.co/',
124
  'Connection' : 'keep-alive',
125
  'Cache-Control' : 'no-cache',
126
  'Upgrade-Insecure-Requests': '1',
127
- ...(opts.headers || {})
128
- }
129
  };
130
  const req = lib.request(reqOpts, res => {
131
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
@@ -135,8 +126,8 @@ function _request(urlStr, opts = {}) {
135
  if (res.statusCode === 404) return resolve(null);
136
  if (res.statusCode !== 200) return resolve(null);
137
  const chunks = [];
138
- let stream = res;
139
- const enc = res.headers['content-encoding'] || '';
140
  if (enc.includes('gzip')) stream = res.pipe(zlib.createGunzip());
141
  else if (enc.includes('br')) stream = res.pipe(zlib.createBrotliDecompress());
142
  else if (enc.includes('deflate')) stream = res.pipe(zlib.createInflate());
@@ -165,60 +156,33 @@ async function getHTML(url, tries = 3) {
165
  return null;
166
  }
167
 
 
168
  async function getBinary(url, tries = 3) {
169
  for (let i = 0; i < tries; i++) {
170
  try {
171
  const r = await _request(url, { binary: true, timeout: DL_TIMEOUT });
172
  if (r !== null) return r;
173
- } catch (e) {
174
- if (i < tries - 1) await sleep(1200 * (i + 1));
175
- }
 
 
 
 
 
 
 
 
 
 
 
176
  }
177
  return null;
178
  }
179
 
180
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
181
- // โ–ˆโ–ˆ TMDB HELPERS
182
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
183
- async function getTMDB(imdbId) {
184
- const ck = `tmdb:${imdbId}`;
185
- const hit = cacheGet(ck);
186
- if (hit !== null) return hit;
187
- try {
188
- const raw = await _request(`${TMDB_BASE}/find/${imdbId}?external_source=imdb_id&api_key=${TMDB_KEY}`, { timeout: 8000 });
189
- if (!raw) { cacheSet(ck, null); return null; }
190
- const d = JSON.parse(raw);
191
- let info = null;
192
- if (d.movie_results?.length) {
193
- const m = d.movie_results[0];
194
- info = { title: m.title || m.original_title, year: m.release_date?.substring(0,4) || null, type: 'movie', tmdbId: m.id };
195
- } else if (d.tv_results?.length) {
196
- const t = d.tv_results[0];
197
- info = { title: t.name || t.original_name, year: t.first_air_date?.substring(0,4) || null, type: 'series', tmdbId: t.id };
198
- }
199
- cacheSet(ck, info);
200
- return info;
201
- } catch (e) { cacheSet(ck, null); return null; }
202
- }
203
-
204
- async function getTMDBSeasonYear(tmdbId, season) {
205
- if (!tmdbId) return null;
206
- const ck = `tmdbseason:${tmdbId}:${season}`;
207
- const hit = cacheGet(ck);
208
- if (hit !== null) return hit;
209
- try {
210
- const raw = await _request(`${TMDB_BASE}/tv/${tmdbId}/season/${season}?api_key=${TMDB_KEY}`, { timeout: 8000 });
211
- if (!raw) { cacheSet(ck, null); return null; }
212
- const d = JSON.parse(raw);
213
- const year = d.air_date?.substring(0, 4) || null;
214
- cacheSet(ck, year);
215
- return year;
216
- } catch (e) { cacheSet(ck, null); return null; }
217
- }
218
-
219
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
220
- // โ–ˆโ–ˆ CACHE
221
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
222
  const _cache = new Map();
223
  function cacheGet(key) {
224
  const item = _cache.get(key);
@@ -235,9 +199,9 @@ function cacheSet(key, val) {
235
  }
236
  const sleep = ms => new Promise(r => setTimeout(r, ms));
237
 
238
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
239
- // โ–ˆโ–ˆ STRING UTILS
240
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
241
  function titleToSlug(title, year) {
242
  const base = (title || '').toLowerCase()
243
  .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
@@ -247,11 +211,12 @@ function titleToSlug(title, year) {
247
  .replace(/^-|-$/g, '');
248
  return year ? `${base}-${year}` : base;
249
  }
 
250
  function _clean(t) { return (t || '').replace(/\s+/g, ' ').trim(); }
251
 
252
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
253
- // โ–ˆโ–ˆ RELEASE PARSING & SCORING
254
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
255
  function parseRelease(text) {
256
  if (!text) return {};
257
  const tokens = {};
@@ -275,299 +240,382 @@ function parseRelease(text) {
275
  function qualityBonus(text) {
276
  if (!text) return 0;
277
  let bonus = 0;
278
- for (const tier of QUALITY_TIERS) {
279
  if (tier.terms.some(rx => rx.test(text))) bonus += tier.bonus;
280
- }
281
  return Math.min(bonus, 0.20);
282
  }
283
 
284
- /**
285
- * Score a subtitle for best-match at [0].
286
- * Higher = better. Components:
287
- * 100 exact S+E match
288
- * 30 release-group match
289
- * 20 source/platform match
290
- * 10 resolution match
291
- * 25 download count (log-scaled, max 25)
292
- * 15 good-vote ratio (โ‰ฅ70%)
293
- * 5 isGood flag
294
- * 0-2 quality bonus from text
295
- */
296
  function scoreSubtitle(sub, streamTokens) {
 
297
  const subText = sub.releaseInfo || sub._rawText || '';
298
  const subTokens = parseRelease(subText);
299
- let score = 0;
300
- if (streamTokens) {
301
- if (streamTokens.season != null && streamTokens.episode != null &&
302
- subTokens.season === streamTokens.season && subTokens.episode === streamTokens.episode) score += 100;
303
- if (streamTokens.source && subTokens.source && subTokens.source === streamTokens.source) score += 20;
304
- if (streamTokens.resolution && subTokens.resolution === streamTokens.resolution) score += 10;
305
- if (streamTokens.group && subTokens.group && subTokens.group === streamTokens.group) score += 30;
306
- }
307
- // Always apply download + vote bonuses so movies are also sorted well
308
- const dl = Math.max(0, sub.downloads || 0);
309
- score += Math.min(25, Math.round(Math.log10(dl + 1) * 7));
310
- const total = (sub.goodVotes || 0) + (sub.badVotes || 0);
311
- if (total >= 3) {
312
- const ratio = sub.goodVotes / total;
313
- if (ratio >= 0.70) score += 15;
314
- else if (ratio >= 0.50) score += 5;
315
- }
316
  if (sub.isGood) score += 5;
317
- score += Math.round(qualityBonus(subText) * 10);
318
  return score;
319
  }
320
 
321
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
322
- // โ–ˆโ–ˆ SLUG PROBE โ€” no longer requires SxxExx in body (just existence)
323
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
324
- async function _probeSlugExists(slug) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  if (!slug || slug.length > 220) return false;
326
  try {
327
- const html = await getHTML(`${BASE}/subtitles/${slug}/english`, 1);
 
 
328
  if (!html || html.length < 500) return false;
329
- // Must not be a search-results page or 404 redirect
330
- if (html.includes('searchbytitle') && !html.includes('<li class="item"') && !html.includes('li.item')) return false;
331
- return html.includes('<li') || html.includes('subtitle') || html.includes('download');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  } catch (_) { return false; }
333
  }
334
 
335
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
336
- // โ–ˆโ–ˆ MOVIE SLUG RESOLUTION
337
- // Strategy: 1) IMDB-ID search, 2) title search, 3) brute-force patterns
338
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
339
- async function resolveMovieSlug(imdbId, titleHint, yearHint) {
340
- const ck = `mslug2:${imdbId}`;
 
 
 
 
 
341
  const hit = cacheGet(ck);
342
  if (hit !== null) return hit;
343
 
344
- const tmdb = await getTMDB(imdbId);
345
- const title = tmdb?.title || titleHint;
346
- const year = tmdb?.year || yearHint;
 
347
 
348
- // 1. Direct IMDB-ID search on subf2m (most reliable โ€” avoids localised title issues)
349
- let slug = await _searchSlugByQuery(imdbId, null, false);
350
- if (slug && await _probeSlugExists(slug)) {
351
- cacheSet(ck, slug); return slug;
 
 
 
 
352
  }
353
 
354
- // 2. Search by original title from TMDB
355
  if (title) {
356
- slug = await _searchSlugByQuery(title, year, false);
357
- if (slug && await _probeSlugExists(slug)) {
358
- cacheSet(ck, slug); return slug;
359
- }
360
 
361
- // 3. Brute-force slug patterns
362
- const candidates = [];
363
- if (year) {
364
- candidates.push(titleToSlug(title, year));
365
- candidates.push(titleToSlug(title, String(+year - 1)));
366
- candidates.push(titleToSlug(title, String(+year + 1)));
367
- }
368
- candidates.push(titleToSlug(title, null));
369
- for (const c of [...new Set(candidates)]) {
370
- await sleep(250);
371
- if (await _probeSlugExists(c)) { cacheSet(ck, c); return c; }
372
  }
373
  }
374
 
375
- cacheSet(ck, null); return null;
 
376
  }
377
 
378
- // Generic search-by-query; isSeries controls scoring
379
- async function _searchSlugByQuery(query, yearHint, isSeries) {
380
- const url = `${BASE}/subtitles/searchbytitle?query=${encodeURIComponent(query)}&l=`;
381
- const html = await getHTML(url);
382
  if (!html) return null;
383
  const $ = cheerio.load(html);
 
 
384
  const results = [];
385
- // Subf2m returns results inside .search-result ul li or similar
386
- const SELS = ['ul.search-result li', '.search-result li', '.content ul li', 'ul li'];
387
- let found = false;
388
- for (const sel of SELS) {
389
- $(sel).each((_, el) => {
390
- const a = $(el).find('a[href]').first();
391
- const href = a.attr('href') || '';
392
- if (!href.startsWith('/subtitles/') || href === '/subtitles/searchbytitle') return;
393
- const parts = href.split('/').filter(Boolean);
394
- if (parts.length < 2) return;
395
- const slug = parts[1];
396
- if (!slug || slug === 'searchbytitle') return;
397
- const text = _clean(a.text());
398
- const yrMatch = text.match(/\((\d{4})\)/);
399
- const resYear = yrMatch ? +yrMatch[1] : null;
400
- let score = 1;
401
- if (query.startsWith('tt')) score += 200; // IMDB-ID hit is definitive
402
- if (yearHint && resYear === +yearHint) score += 10;
403
- results.push({ slug, score });
404
- found = true;
405
- });
406
- if (found) break;
407
- }
408
- if (!results.length) return null;
409
- results.sort((a, b) => b.score - a.score);
410
- return results[0].slug;
411
- }
412
-
413
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
414
- // โ–ˆโ–ˆ SERIES SLUG RESOLUTION
415
- // Strategy: 1) smart TMDB patterns, 2) site search, 3) base slug
416
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
417
- async function resolveSeriesSlug(imdbId, season) {
418
- const ck = `sslug2:${imdbId}:${season}`;
419
- const hit = cacheGet(ck);
420
- if (hit !== null) return hit;
421
-
422
- const tmdb = await getTMDB(imdbId);
423
- const title = tmdb?.title;
424
- if (!title) { cacheSet(ck, null); return null; }
425
-
426
- const seasonYear = tmdb?.tmdbId ? await getTMDBSeasonYear(tmdb.tmdbId, season) : null;
427
 
428
- // 1. Smart slug patterns
429
- let slug = await _smartSeriesPatterns(title, season, seasonYear, tmdb?.year);
430
- if (slug) { cacheSet(ck, slug); return slug; }
 
 
431
 
432
- // 2. Search-based resolution (parses "Title - ORDINAL season (YEAR)" exactly)
433
- slug = await _searchSeriesSlug(title, season, seasonYear, imdbId);
434
- if (slug) { cacheSet(ck, slug); return slug; }
435
 
436
- // 3. Base slug without season specifier (some old shows use bare title)
437
- const baseSlug = titleToSlug(title, null);
438
- if (await _probeSlugExists(baseSlug)) { cacheSet(ck, baseSlug); return baseSlug; }
439
 
440
- cacheSet(ck, null); return null;
 
 
 
 
441
  }
442
 
443
- /**
444
- * Build and probe slug patterns from high to low probability.
445
- * Based on real subf2m URL patterns observed in the wild.
446
- */
447
- async function _smartSeriesPatterns(title, season, seasonYear, showYear) {
448
  const base = titleToSlug(title, null);
449
  const ord = ORDINALS[season] || '';
450
  const pad = String(season).padStart(2, '0');
451
  const patterns = [];
452
 
453
- // ๏ฟฝ๏ฟฝโ”€ Tier 1: exact season year from TMDB โ”€โ”€
454
  if (seasonYear) {
455
  if (ord) {
456
  patterns.push(`${base}-${ord}-season-${seasonYear}`);
457
- patterns.push(`${base}--${ord}-season-${seasonYear}`); // double-dash variant
458
  patterns.push(`${base}-${ord}-season-tv-${seasonYear}`);
 
459
  }
460
  patterns.push(`${base}-season-${season}-${seasonYear}`);
461
  patterns.push(`${base}-s${pad}-${seasonYear}`);
462
- patterns.push(`${base}-${seasonYear}`); // Fallout (2024) style
463
  }
464
 
465
- // โ”€โ”€ Tier 2: ordinal & numeric without year โ”€โ”€
466
  if (ord) {
467
  patterns.push(`${base}-${ord}-season`);
468
  patterns.push(`${base}--${ord}-season`);
 
 
469
  }
470
  patterns.push(`${base}-season-${season}`);
471
  patterns.push(`${base}-s${pad}`);
472
- patterns.push(`${base}-season-${season}-${season}`); // some numeric dupe patterns
473
 
474
- // โ”€โ”€ Tier 3: adjacent years (handles delayed season premieres) โ”€โ”€
475
  const yearBase = seasonYear ? +seasonYear : (showYear ? +showYear : null);
476
  if (yearBase) {
477
- for (const yr of [yearBase - 1, yearBase + 1]) {
478
- if (ord) patterns.push(`${base}-${ord}-season-${yr}`);
 
 
 
479
  patterns.push(`${base}-season-${season}-${yr}`);
480
  patterns.push(`${base}-${yr}`);
481
  }
482
  }
483
 
484
- // โ”€โ”€ Tier 4: title with show year + ordinal (older indexing style) โ”€โ”€
485
- if (showYear && ord) {
486
- patterns.push(`${base}-${showYear}-${ord}-season`);
487
- }
488
-
489
  const unique = [...new Set(patterns)].filter(Boolean);
490
- console.log(`[slug-probe] Testing ${unique.length} patterns for "${title}" S${season}`);
491
 
492
  for (const s of unique) {
493
  await sleep(120);
494
- if (await _probeSlugExists(s)) {
495
- console.log(`[slug-probe] HIT: ${s}`);
496
  return s;
497
  }
498
  }
499
  return null;
500
  }
501
 
502
- /**
503
- * Search subf2m for the series and pick the result whose display text
504
- * matches "title - ORDINAL season (YEAR)" as closely as possible.
505
- * This is how bazarr v1.4+ resolves series โ€” the most reliable fallback.
506
- */
507
- async function _searchSeriesSlug(title, season, seasonYear, imdbId) {
508
- const ord = (ORDINALS[season] || '').toLowerCase();
509
-
510
- // Try IMDB-ID first (avoids localised-title mismatches)
511
- for (const query of [imdbId, title]) {
512
- if (!query) continue;
513
- const url = `${BASE}/subtitles/searchbytitle?query=${encodeURIComponent(query)}&l=`;
514
- const html = await getHTML(url);
515
- if (!html) continue;
516
- const $ = cheerio.load(html);
517
- const firstWord = titleToSlug(title, null).split('-')[0];
518
- const results = [];
519
- const seen = new Set();
520
 
521
- $('a[href]').each((_, a) => {
522
- const href = $(a).attr('href') || '';
523
- if (!href.startsWith('/subtitles/')) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  const parts = href.split('/').filter(Boolean);
525
  if (parts.length < 2) return;
526
  const slug = parts[1];
527
- if (!slug || slug === 'searchbytitle' || seen.has(slug)) return;
528
- seen.add(slug);
529
- const slugLow = slug.toLowerCase();
530
- if (!slugLow.startsWith(firstWord)) return;
531
-
532
- let score = 0;
533
- if (ord && slugLow.includes(`${ord}-season`)) score += 100;
534
- if (ord && slugLow.includes(`${ord}season`)) score += 90;
535
- if (slugLow.includes(`season-${season}`)) score += 80;
536
- if (slugLow.includes(`-s${pad(season)}`)) score += 80;
537
- if (seasonYear && slugLow.includes(String(seasonYear))) score += 20;
538
- if (score > 0) results.push({ slug, score });
539
  });
 
 
540
 
541
- if (!results.length) {
542
- // If we find the bare title (e.g. "fallout-2024"), that works for single-season shows
543
- $('a[href]').each((_, a) => {
544
- const href = $(a).attr('href') || '';
545
- if (!href.startsWith('/subtitles/')) return;
546
- const parts = href.split('/').filter(Boolean);
547
- if (parts.length < 2) return;
548
- const slug = parts[1];
549
- if (!slug || seen.has(slug)) return;
550
- seen.add(slug);
551
- if (slug.toLowerCase().startsWith(firstWord)) results.push({ slug, score: 5 });
552
- });
553
- }
554
 
555
- if (results.length) {
556
- results.sort((a, b) => b.score - a.score);
557
- for (const r of results.slice(0, 6)) {
558
- await sleep(150);
559
- if (await _probeSlugExists(r.slug)) return r.slug;
560
- }
 
 
 
 
 
561
  }
562
- }
563
- return null;
564
  }
565
- function pad(n) { return String(n).padStart(2, '0'); }
566
 
567
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
568
- // โ–ˆโ–ˆ SUBTITLE LIST SCRAPING
569
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
570
- async function getSubsList(slug, lang, season, episode) {
571
  if (!ALLOWED_LANGS.has(lang)) return [];
572
  const ck = `list2:${slug}:${lang}:${episode || 0}`;
573
  const hit = cacheGet(ck);
@@ -577,40 +625,47 @@ async function getSubsList(slug, lang, season, episode) {
577
  const html = await getHTML(url);
578
  if (!html) { cacheSet(ck, []); return []; }
579
  const $ = cheerio.load(html);
580
- let raw = [];
 
 
581
  $('li.item').each((_, el) => { _extractItem($, el, lang, raw); });
582
 
 
 
 
 
 
 
583
  if (!raw.length) {
584
- // Fallback: any anchor that looks like a detail link
585
  $('a[href]').each((_, a) => {
586
  const href = $(a).attr('href') || '';
587
  if (!href.includes(`/${lang}/`) || !/\/\d+$/.test(href)) return;
588
  const parts = href.split('/');
589
  const subId = parts[parts.length - 1];
590
  if (!subId || !/^\d+$/.test(subId)) return;
591
- raw.push({
592
- subId, detailUrl: href.startsWith('http') ? href : `${BASE}${href}`,
593
- isGood: false, releaseInfo: '', comment: '', authorNote: '',
594
- lang, goodVotes: 0, badVotes: 0, downloads: 0, _rawText: ''
595
- });
596
  });
597
  }
598
 
599
- // For series: filter to exact season+episode if possible
600
- if (season && episode) {
601
  const expected = `s${String(season).padStart(2,'0')}e${String(episode).padStart(2,'0')}`;
602
  const filtered = raw.filter(item => {
603
  const txt = (item._rawText || item.releaseInfo || '').toLowerCase();
604
  return txt.includes(expected);
605
  });
606
  if (filtered.length) raw = filtered;
607
- // else keep all (season pack / different naming)
608
  }
609
 
 
610
  const seen = new Set();
611
  const unique = raw.filter(s => { if (seen.has(s.subId)) return false; seen.add(s.subId); return true; });
612
- const enriched = (await Promise.all(unique.slice(0, MAX_DETAIL).map(s => _enrichSub(s)))).filter(Boolean);
 
 
613
  const human = enriched.filter(s => !isAISub(`${s.releaseInfo} ${s.comment} ${s.authorNote}`));
 
614
  cacheSet(ck, human);
615
  return human;
616
  }
@@ -619,33 +674,40 @@ function _extractItem($, el, lang, out) {
619
  const $el = $(el);
620
  const rawText = _clean($el.text());
621
  let detailHref = null;
 
622
  $el.find('a').each((_, a) => {
623
  const h = $(a).attr('href') || '';
624
  if (h.includes(`/${lang}/`) && /\/\d+$/.test(h)) detailHref = h;
625
  });
 
626
  if (!detailHref) return;
627
  const hLow = detailHref.toLowerCase();
628
  if (!hLow.includes(`/${lang}/`)) return;
629
  if (OTHER_LANGS.some(ol => hLow.includes(`/${ol}/`))) return;
 
630
  const parts = detailHref.split('/');
631
  const subId = parts[parts.length - 1];
632
  if (!subId || !/^\d+$/.test(subId)) return;
 
633
  const isGood = (
634
  $el.find('.green-icon, .positive-icon').length > 0 ||
635
  $el.find('img[alt="good"], img[alt="Positive"], img[alt="positive"]').length > 0 ||
636
  $el.hasClass('good') || $el.hasClass('positive') ||
637
  ($el.attr('class') || '').toLowerCase().includes('good')
638
  );
 
639
  const comment = _clean($el.find('p.comment, .comment').first().text());
640
  const releaseLines = $el.find('.r li, ul.release li, ul.scrolllist li, ul li')
641
- .map((_, li) => $(li).text().trim()).get().filter(Boolean);
642
- const releaseInfo = (releaseLines.join(' | ').substring(0, 130)) || _clean($el.text()).substring(0, 130);
 
643
  if (isAISub(`${comment} ${releaseInfo}`)) return;
 
644
  out.push({
645
  subId,
646
- detailUrl : detailHref.startsWith('http') ? detailHref : `${BASE}${detailHref}`,
647
  isGood, releaseInfo, comment, authorNote: '', lang,
648
- goodVotes: 0, badVotes: 0, downloads: 0, _rawText: rawText
649
  });
650
  }
651
 
@@ -653,54 +715,47 @@ async function _enrichSub(sub) {
653
  const ck = `det2:${sub.subId}`;
654
  const hit = cacheGet(ck);
655
  if (hit !== null) { if (hit.__aiBlocked) return null; return { ...sub, ...hit }; }
 
656
  const html = await getHTML(sub.detailUrl);
657
  if (!html) return sub;
658
- const $ = cheerio.load(html);
659
- const txt = $.root().text();
660
- const goodVotes = +(txt.match(/voted\s+as\s+good\s+by[:\s]*(\d+)/i)?.[1] ?? 0);
661
- const badVotes = +(txt.match(/voted\s+as\s+bad\s+by[:\s]*(\d+)/i)?.[1] ?? 0);
662
- const downloads = +(txt.match(/downloads?[:\s]*(\d+)/i)?.[1] ?? 0);
663
- const total = goodVotes + badVotes;
664
- const ratio = total >= 3 ? goodVotes / total : null;
665
- const isGood = sub.isGood || (ratio !== null && ratio >= 0.70 && goodVotes >= 2);
666
  const authorNote = _clean($('p.comment, .comment, .notes, .author-comment').first().text());
667
- const fullCheck = `${authorNote} ${sub.releaseInfo} ${sub.comment}`;
668
- if (isAISub(fullCheck)) { cacheSet(ck, { __aiBlocked: true }); return null; }
 
 
 
 
669
  const relLines = $('ul.release li, .release-names li, ul.scrolllist li').map((_, e) => $(e).text().trim()).get();
670
  const releaseInfo = relLines.length ? relLines.join(' | ').substring(0, 130) : sub.releaseInfo;
671
- const patch = { releaseInfo, goodVotes, badVotes, downloads, isGood, authorNote };
672
- cacheSet(ck, patch);
673
- return { ...sub, ...patch };
 
674
  }
675
 
676
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
677
- // โ–ˆโ–ˆ DOWNLOAD & SRT EXTRACTION (with proxy fallback chain)
678
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
679
  async function fetchSRT(downloadUrl) {
680
  const ck = `srt2:${downloadUrl}`;
681
  const hit = cacheGet(ck);
682
  if (hit !== null) return hit;
683
  try {
684
- // Try direct first (no proxy overhead)
685
- let buf = await getBinary(downloadUrl);
686
-
687
- // Proxy fallback chain if direct fails
688
- if (!buf) {
689
- for (const proxyFn of [PROXY_1, PROXY_2, PROXY_3]) {
690
- try {
691
- buf = await getBinary(proxyFn(downloadUrl));
692
- if (buf) { console.log(`[proxy] success with ${proxyFn(downloadUrl).substring(0,40)}`); break; }
693
- } catch (_) {}
694
- await sleep(300);
695
- }
696
- }
697
-
698
  if (!buf) return null;
699
  const content = _extractFromZip(buf) ?? _tryDirect(buf);
700
  if (!content) return null;
701
  cacheSet(ck, content);
702
  return content;
703
- } catch (e) { return null; }
704
  }
705
 
706
  function _extractFromZip(buf) {
@@ -714,7 +769,7 @@ function _extractFromZip(buf) {
714
  })
715
  .sort((a, b) => (RANK[_ext(a.entryName)] ?? 9) - (RANK[_ext(b.entryName)] ?? 9));
716
  for (const f of files) {
717
- const raw = zip.readFile(f);
718
  if (!raw) continue;
719
  const text = _decode(raw);
720
  if (_isSub(text)) return text;
@@ -722,6 +777,7 @@ function _extractFromZip(buf) {
722
  } catch (_) {}
723
  return null;
724
  }
 
725
  function _tryDirect(buf) { const t = _decode(buf); return _isSub(t) ? t : null; }
726
  const _ext = n => (n.match(/(\.[^.]+)$/)?.[1]?.toLowerCase() ?? '');
727
  const _isSub = t => typeof t === 'string' && t.includes('-->') && t.length > 50;
@@ -732,18 +788,40 @@ function _decode(buf) {
732
  return buf.toString('latin1');
733
  }
734
 
735
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
736
- // โ–ˆโ–ˆ ROUTES
737
- // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
738
  app.get('/manifest.json', (req, res) => res.json({
739
- id : 'org.van.vansub2fmplus.v1',
740
- name : 'VanSub2FM+',
741
- description: 'Human-Verified Arabic & English subtitles from subf2m.co. Anti-AI. High accuracy.',
742
- version : '1.0.0',
743
  resources : ['subtitles'],
744
  types : ['movie', 'series'],
745
  idPrefixes : ['tt'],
746
- catalogs : []
747
  }));
748
 
749
  app.get('/sub/:enc.srt', async (req, res) => {
@@ -753,9 +831,9 @@ app.get('/sub/:enc.srt', async (req, res) => {
753
  if (!dlUrl.startsWith('https://subf2m.co/') || !dlUrl.includes('/subtitles/') || !dlUrl.endsWith('/download'))
754
  return res.status(403).end('Forbidden');
755
  const content = await fetchSRT(dlUrl);
756
- if (!content) return res.status(404).end('Subtitle not found');
757
- res.setHeader('Content-Type', 'text/plain; charset=utf-8');
758
- res.setHeader('Cache-Control', 'public, max-age=86400');
759
  res.setHeader('Content-Disposition', 'inline; filename="subtitle.srt"');
760
  res.end(content);
761
  });
@@ -765,7 +843,6 @@ app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
765
  const { type, id } = req.params;
766
  if (!id?.startsWith('tt')) return res.json({ subtitles: [] });
767
 
768
- // Short-lived result cache (don't cache for more than 30 min at result level)
769
  const ck = `result2:${id}`;
770
  const hit = cacheGet(ck);
771
  if (hit) return res.json({ subtitles: hit });
@@ -788,51 +865,44 @@ app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
788
  slug = await resolveSeriesSlug(imdbId, season);
789
  } else {
790
  const tmdb = await getTMDB(imdbId);
791
- slug = await resolveMovieSlug(imdbId, tmdb?.title || null, tmdb?.year || null);
792
  }
793
 
794
  if (!slug) return res.json({ subtitles: [] });
795
 
796
  const [engRes, araRes] = await Promise.allSettled([
797
- getSubsList(slug, 'english', season, episode),
798
- getSubsList(slug, 'arabic', season, episode)
799
  ]);
800
 
801
- let engSubs = engRes.status === 'fulfilled' ? engRes.value : [];
802
- let araSubs = araRes.status === 'fulfilled' ? araRes.value : [];
803
-
804
- // Sort by score (best first = subtitle[0])
805
- const rank = (subs) => subs
806
- .map(s => ({ ...s, _score: scoreSubtitle(s, streamTokens) }))
807
- .sort((a, b) => b._score - a._score)
808
- .slice(0, MAX_PER_LANG);
809
 
810
- engSubs = rank(engSubs);
811
- araSubs = rank(araSubs);
 
812
 
813
  const proto = req.headers['x-forwarded-proto'] || (req.socket?.encrypted ? 'https' : 'http');
814
  const host = req.headers['x-forwarded-host'] || req.headers.host;
815
  const origin = `${proto}://${host}`;
816
 
817
  const build = (subs, langCode, flag) => subs.map(s => {
818
- const dlUrl = `${s.detailUrl}/download`;
819
- const enc = Buffer.from(dlUrl).toString('base64url');
820
- const badge = s.isGood ? ' โญ' : '';
821
- const votes = s.goodVotes > 0 ? ` [๐Ÿ‘${s.goodVotes}]` : '';
822
- const dl = s.downloads > 0 ? ` [โฌ‡${s.downloads}]` : '';
823
- const name = `${flag}${badge}${votes}${dl} ${s.releaseInfo}`.trim().substring(0, 100);
824
- return { id: `vsub2-${langCode}-${s.subId}`, url: `${origin}/sub/${enc}.srt`, lang: langCode, name };
825
  });
826
 
827
- const subtitles = [...build(engSubs, 'eng', '๐Ÿ‡บ๐Ÿ‡ธ'), ...build(araSubs, 'ara', '๐Ÿ‡ธ๐Ÿ‡ฆ')];
828
  cacheSet(ck, subtitles);
829
  res.json({ subtitles });
830
  } catch (e) {
831
- console.error('[route-error]', e);
832
  res.json({ subtitles: [] });
833
  }
834
  });
835
 
836
- app.listen(PORT, '0.0.0.0', () =>
837
- console.log(`โœ… VanSub2FM+ v1.0.0 โ†’ http://0.0.0.0:${PORT}`)
838
- );
 
1
  'use strict';
 
2
  const express = require('express');
3
  const cheerio = require('cheerio');
4
  const AdmZip = require('adm-zip');
 
18
  next();
19
  });
20
 
21
+ // ================================================================
22
+ // PROXY CONFIG โ† edit these three lines to swap proxies
23
+ // ================================================================
24
+ const PROXY_1 = 'https://corsproxy.io/?url=';
25
+ const PROXY_2 = 'https://api.codetabs.com/v1/proxy?quest=';
26
+ const PROXY_3 = 'https://thingproxy.freeboard.io/fetch/';
27
+ // ================================================================
28
 
 
 
 
29
  const BASE = 'https://subf2m.co';
30
+ const TMDB_KEY = process.env.TMDB_KEY || '30166e2db6b420d3f230808cd1bb2c90';
31
  const TMDB_BASE = 'https://api.themoviedb.org/3';
32
  const MAX_PER_LANG = 4;
33
+ const CACHE_TTL = 6 * 60 * 60 * 1000;
34
+ const REQ_TIMEOUT = 18_000;
35
+ const DL_TIMEOUT = 30_000;
36
  const MAX_DETAIL = 30;
37
 
38
+ // Full ordinal list covering seasons 1โ€“30
39
  const ORDINALS = [
40
  '', 'first', 'second', 'third', 'fourth', 'fifth',
41
  'sixth', 'seventh', 'eighth', 'ninth', 'tenth',
42
  'eleventh', 'twelfth', 'thirteenth', 'fourteenth', 'fifteenth',
43
+ 'sixteenth', 'seventeenth', 'eighteenth', 'nineteenth', 'twentieth',
44
+ 'twenty-first', 'twenty-second', 'twenty-third', 'twenty-fourth', 'twenty-fifth',
45
+ 'twenty-sixth', 'twenty-seventh', 'twenty-eighth', 'twenty-ninth', 'thirtieth',
46
  ];
47
 
48
  const ALLOWED_LANGS = new Set(['english', 'arabic']);
49
 
 
50
  const OTHER_LANGS = [
51
+ 'french','spanish','portuguese','german','italian','turkish','persian','farsi',
52
+ 'hindi','russian','polish','dutch','swedish','norwegian','danish','finnish',
53
+ 'korean','japanese','chinese','malay','indonesian','vietnamese','thai','greek',
54
+ 'hungarian','czech','romanian','bulgarian','hebrew','ukrainian','serbian',
55
+ 'croatian','slovak','slovenian','latvian','estonian','lithuanian','albanian',
56
+ 'azerbaijani','bosnian','catalan','georgian','icelandic','macedonian','mongolian',
57
+ 'punjabi','sinhala','tamil','bengali','urdu','nepali','khmer','burmese',
58
+ 'afrikaans','swahili',
 
59
  ];
60
 
 
 
 
61
  const QUALITY_TIERS = [
62
  { bonus: 0.13, terms: [/\bbluray\b/i,/\bblu[- ]ray\b/i,/\bbdrip\b/i,/\bbdremux\b/i,/\b4k\b/i,/\b2160p\b/i,/\b1080p\b/i] },
63
  { bonus: 0.10, terms: [/\bweb[- ]?dl\b/i,/\bwebrip\b/i,/\bamzn\b/i,/\bnflx\b/i,/\bhbo\b/i,/\bdsnp\b/i,/\bappletv\b/i,/\batvp\b/i,/\bpcok\b/i] },
64
  { bonus: 0.05, terms: [/\bhdtv\b/i,/\b720p\b/i,/\bhdrip\b/i] },
65
+ { bonus: 0.08, terms: [/\bproper\b/i,/\brepack\b/i,/\breal\.proper\b/i,/\bsynced\b/i,/\bsync\b/i,/\bfixed\b/i,/\bcorrected\b/i,/\bv2\b/i,/\bv3\b/i] },
66
  ];
67
 
 
 
 
68
  const AI_PATTERNS = [
69
  /\ba\.i\.\b/i,/\bai[\s\-_]sub(title)?s?\b/i,/\bai[\s\-_](translat|generat|correct|made|creat)/i,
70
  /(translat|generat|creat|made|sub(title)?s?)\s+(by\s+)?ai\b/i,/machine[\s\-_]translat/i,
 
75
  /\bllama\b/i,/neural[\s\-_](translat|sub|net|machine)/i,/\bnmt\b/i,
76
  /artificial[\s\-_]intelligence/i,/computer[\s\-_](translat|generat)/i,
77
  ];
78
+
79
  const AI_QUICK_HITS = [
80
  'a.i','chatgpt','deepl','whisper','openai','gemini','claude','llama',
81
  'machine translat','auto translat','auto-translat','auto generat','autosub','neural',
82
  'yandex','bing translat','google translat','amazon translat','microsoft translat','nmt',
83
  'artificial intel',' ai sub','ai-sub','ai translat','translated by ai','generated by ai',
84
  ];
85
+
86
  function isAISub(text) {
87
  if (!text) return false;
88
  const lo = text.toLowerCase();
89
+ const maybeAI = AI_QUICK_HITS.some(q => lo.includes(q)) || /\bai\b/.test(lo);
90
  if (!maybeAI) return false;
91
  return AI_PATTERNS.some(rx => rx.test(text));
92
  }
93
 
 
 
 
94
  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';
95
 
96
+ // ================================================================
97
+ // HTTP CORE
98
+ // ================================================================
99
  function _request(urlStr, opts = {}) {
100
  return new Promise((resolve, reject) => {
101
  const parsed = new URL(urlStr);
 
108
  method : 'GET',
109
  headers : {
110
  'User-Agent' : UA,
111
+ 'Accept' : opts.binary ? 'application/zip,application/octet-stream,*/*' : 'text/html,application/xhtml+xml,*/*;q=0.8',
 
 
112
  'Accept-Language' : 'en-US,en;q=0.9',
113
  'Accept-Encoding' : 'gzip, deflate',
114
  'Referer' : 'https://subf2m.co/',
115
  'Connection' : 'keep-alive',
116
  'Cache-Control' : 'no-cache',
117
  'Upgrade-Insecure-Requests': '1',
118
+ ...(opts.headers || {}),
119
+ },
120
  };
121
  const req = lib.request(reqOpts, res => {
122
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
 
126
  if (res.statusCode === 404) return resolve(null);
127
  if (res.statusCode !== 200) return resolve(null);
128
  const chunks = [];
129
+ let stream = res;
130
+ const enc = res.headers['content-encoding'] || '';
131
  if (enc.includes('gzip')) stream = res.pipe(zlib.createGunzip());
132
  else if (enc.includes('br')) stream = res.pipe(zlib.createBrotliDecompress());
133
  else if (enc.includes('deflate')) stream = res.pipe(zlib.createInflate());
 
156
  return null;
157
  }
158
 
159
+ // Binary with proxy chain fallback
160
  async function getBinary(url, tries = 3) {
161
  for (let i = 0; i < tries; i++) {
162
  try {
163
  const r = await _request(url, { binary: true, timeout: DL_TIMEOUT });
164
  if (r !== null) return r;
165
+ } catch (_) {}
166
+ }
167
+ // Proxy chain
168
+ const enc = encodeURIComponent(url);
169
+ const proxied = [
170
+ `${PROXY_1}${enc}`,
171
+ `${PROXY_2}${enc}`,
172
+ `${PROXY_3}${url}`,
173
+ ];
174
+ for (const pUrl of proxied) {
175
+ try {
176
+ const r = await _request(pUrl, { binary: true, timeout: DL_TIMEOUT });
177
+ if (r && r.length > 50) return r;
178
+ } catch (_) {}
179
  }
180
  return null;
181
  }
182
 
183
+ // ================================================================
184
+ // CACHE
185
+ // ================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  const _cache = new Map();
187
  function cacheGet(key) {
188
  const item = _cache.get(key);
 
199
  }
200
  const sleep = ms => new Promise(r => setTimeout(r, ms));
201
 
202
+ // ================================================================
203
+ // STRING UTILS
204
+ // ================================================================
205
  function titleToSlug(title, year) {
206
  const base = (title || '').toLowerCase()
207
  .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
 
211
  .replace(/^-|-$/g, '');
212
  return year ? `${base}-${year}` : base;
213
  }
214
+
215
  function _clean(t) { return (t || '').replace(/\s+/g, ' ').trim(); }
216
 
217
+ // ================================================================
218
+ // RELEASE PARSING & SCORING
219
+ // ================================================================
220
  function parseRelease(text) {
221
  if (!text) return {};
222
  const tokens = {};
 
240
  function qualityBonus(text) {
241
  if (!text) return 0;
242
  let bonus = 0;
243
+ for (const tier of QUALITY_TIERS)
244
  if (tier.terms.some(rx => rx.test(text))) bonus += tier.bonus;
 
245
  return Math.min(bonus, 0.20);
246
  }
247
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  function scoreSubtitle(sub, streamTokens) {
249
+ if (!streamTokens) return 0;
250
  const subText = sub.releaseInfo || sub._rawText || '';
251
  const subTokens = parseRelease(subText);
252
+ let score = 0;
253
+ if (streamTokens.season != null && streamTokens.episode != null &&
254
+ subTokens.season === streamTokens.season && subTokens.episode === streamTokens.episode)
255
+ score += 100;
256
+ if (streamTokens.source && subTokens.source && subTokens.source === streamTokens.source)
257
+ score += 20;
258
+ if (streamTokens.resolution && subTokens.resolution === streamTokens.resolution)
259
+ score += 10;
260
+ if (streamTokens.group && subTokens.group && subTokens.group === streamTokens.group)
261
+ score += 30;
 
 
 
 
 
 
 
262
  if (sub.isGood) score += 5;
263
+ score += qualityBonus(subText) * 10;
264
  return score;
265
  }
266
 
267
+ // ================================================================
268
+ // TMDB
269
+ // ================================================================
270
+ async function getTMDB(imdbId) {
271
+ const ck = `tmdb:${imdbId}`;
272
+ const hit = cacheGet(ck);
273
+ if (hit !== null) return hit;
274
+ try {
275
+ const raw = await _request(
276
+ `${TMDB_BASE}/find/${imdbId}?external_source=imdb_id&api_key=${TMDB_KEY}`,
277
+ { timeout: 10_000 },
278
+ );
279
+ if (!raw) { cacheSet(ck, null); return null; }
280
+ const d = JSON.parse(raw);
281
+ let info = null;
282
+ if (d.movie_results?.length) {
283
+ const m = d.movie_results[0];
284
+ info = { title: m.title || m.original_title, year: m.release_date?.substring(0,4) || null, type: 'movie', tmdbId: m.id };
285
+ } else if (d.tv_results?.length) {
286
+ const t = d.tv_results[0];
287
+ info = { title: t.name || t.original_name, year: t.first_air_date?.substring(0,4) || null, type: 'series', tmdbId: t.id };
288
+ }
289
+ cacheSet(ck, info);
290
+ return info;
291
+ } catch (e) { cacheSet(ck, null); return null; }
292
+ }
293
+
294
+ async function getTMDBSeasonYear(tmdbId, season) {
295
+ if (!tmdbId) return null;
296
+ const ck = `tmdbsy:${tmdbId}:${season}`;
297
+ const hit = cacheGet(ck);
298
+ if (hit !== null) return hit;
299
+ try {
300
+ const raw = await _request(`${TMDB_BASE}/tv/${tmdbId}/season/${season}?api_key=${TMDB_KEY}`, { timeout: 10_000 });
301
+ if (!raw) { cacheSet(ck, null); return null; }
302
+ const year = JSON.parse(raw).air_date?.substring(0, 4) || null;
303
+ cacheSet(ck, year);
304
+ return year;
305
+ } catch (e) { cacheSet(ck, null); return null; }
306
+ }
307
+
308
+ // ================================================================
309
+ // SLUG PROBING โ€” FIXED
310
+ // No longer requires S##E## on the page โ€” any item list qualifies.
311
+ // ================================================================
312
+ async function _probeSlug(slug, opts = {}) {
313
  if (!slug || slug.length > 220) return false;
314
  try {
315
+ const lang = opts.lang || 'english';
316
+ const url = `${BASE}/subtitles/${slug}/${lang}`;
317
+ const html = await getHTML(url, 1);
318
  if (!html || html.length < 500) return false;
319
+ // Must have subtitle items
320
+ if (!html.includes('<li') && !html.includes('subtitle') && !html.includes('download')) return false;
321
+ // If we need to verify season, do a looser check
322
+ if (opts.season) {
323
+ const s = Number(opts.season);
324
+ const pad = String(s).padStart(2, '0');
325
+ const ord = (ORDINALS[s] || '').toLowerCase();
326
+ // Accept if any season-related text is found, OR if there are item listings
327
+ const hasSeasonRef = (
328
+ html.includes(`S${pad}E`) || html.includes(`s${pad}e`) ||
329
+ html.includes(`season ${s}`) || html.includes(`Season ${s}`) ||
330
+ (ord && html.toLowerCase().includes(`${ord} season`)) ||
331
+ html.includes(`li class="item"`) ||
332
+ html.includes('class="r"')
333
+ );
334
+ return hasSeasonRef;
335
+ }
336
+ return html.includes('<li') || html.includes('item');
337
  } catch (_) { return false; }
338
  }
339
 
340
+ // ================================================================
341
+ // SERIES SLUG RESOLUTION โ€” FIXED
342
+ // Key insight from bazarr issue #2617 and #2616:
343
+ // 1. Subf2m search returns entries like "Young Sheldon - first season (2017)"
344
+ // with a TRAILING SPACE before closing tag โ€” must trim.
345
+ // 2. Search by IMDb ID works: ?query=tt1234567
346
+ // 3. The slug for a series season is: show-name-first-season OR
347
+ // show-name-first-season-2017
348
+ // ================================================================
349
+ async function resolveSeriesSlug(imdbId, season) {
350
+ const ck = `sslug2:${imdbId}:${season}`;
351
  const hit = cacheGet(ck);
352
  if (hit !== null) return hit;
353
 
354
+ const tmdb = await getTMDB(imdbId);
355
+ const title = tmdb?.title;
356
+ const showYear = tmdb?.year;
357
+ const seasonYear = tmdb?.tmdbId ? await getTMDBSeasonYear(tmdb.tmdbId, season) : null;
358
 
359
+ // Strategy 1: Search by IMDb ID (most reliable)
360
+ let slug = await _searchSeriesSlugByQuery(imdbId, season, title, seasonYear, showYear);
361
+ if (slug) { cacheSet(ck, slug); return slug; }
362
+
363
+ // Strategy 2: Search by title
364
+ if (title) {
365
+ slug = await _searchSeriesSlugByQuery(title, season, title, seasonYear, showYear);
366
+ if (slug) { cacheSet(ck, slug); return slug; }
367
  }
368
 
369
+ // Strategy 3: Smart pattern brute-force
370
  if (title) {
371
+ slug = await _smartPatternSlugs(title, season, seasonYear, showYear);
372
+ if (slug) { cacheSet(ck, slug); return slug; }
373
+ }
 
374
 
375
+ // Strategy 4: Bare slug (complete series page)
376
+ if (title) {
377
+ const bare = titleToSlug(title, null);
378
+ if (await _probeSlug(bare, { season })) { cacheSet(ck, bare); return bare; }
379
+ // Try with show year
380
+ if (showYear) {
381
+ const withYear = titleToSlug(title, showYear);
382
+ if (await _probeSlug(withYear, { season })) { cacheSet(ck, withYear); return withYear; }
 
 
 
383
  }
384
  }
385
 
386
+ cacheSet(ck, null);
387
+ return null;
388
  }
389
 
390
+ async function _searchSeriesSlugByQuery(query, season, titleHint, seasonYear, showYear) {
391
+ const searchUrl = `${BASE}/subtitles/searchbytitle?query=${encodeURIComponent(query)}&l=`;
392
+ const html = await getHTML(searchUrl);
 
393
  if (!html) return null;
394
  const $ = cheerio.load(html);
395
+ const ord = (ORDINALS[season] || '').toLowerCase();
396
+ const firstWord = titleHint ? titleToSlug(titleHint, null).split('-')[0] : '';
397
  const results = [];
398
+ const seen = new Set();
399
+
400
+ // The bazarr fix: subf2m titles in HTML have trailing spaces.
401
+ // Regex matches: "Show Name - ORDINAL season (YEAR)" or "Show Name (YEAR)"
402
+ // The series regex from bazarr (with the \s*$ fix):
403
+ // ^(.+?) [-\(]\s?(.*?) (season|series)\)?( \((\d{4})\))?\s*$
404
+ const SERIES_RE = /^(.+?)\s*[-โ€“]\s*(.*?)\s*(season|series)\s*(\((\d{4})\))?\s*$/i;
405
+ const MOVIE_RE = /^(.+?)\s*\((\d{4})\)\s*$/;
406
+
407
+ $('a[href]').each((_, a) => {
408
+ const href = $(a).attr('href') || '';
409
+ if (!href.startsWith('/subtitles/')) return;
410
+ const parts = href.split('/').filter(Boolean);
411
+ if (parts.length < 2) return;
412
+ const slug = parts[1];
413
+ if (!slug || slug === 'searchbytitle' || seen.has(slug)) return;
414
+ seen.add(slug);
415
+
416
+ const rawText = _clean($(a).text());
417
+ const slugLow = slug.toLowerCase();
418
+
419
+ // Must start with show's first word for disambiguation
420
+ if (firstWord && !slugLow.startsWith(firstWord)) return;
421
+
422
+ let score = 0;
423
+
424
+ // Check title text for season match
425
+ const seriesMatch = SERIES_RE.exec(rawText);
426
+ if (seriesMatch) {
427
+ const subOrd = (seriesMatch[2] || '').trim().toLowerCase();
428
+ const subYear = seriesMatch[5];
429
+ // Ordinal match (e.g. "first season")
430
+ if (ord && subOrd === ord) score += 200;
431
+ else if (ord && subOrd.startsWith(ord)) score += 150;
432
+ // Numeric season as ordinal
433
+ if (subOrd === String(season)) score += 100;
434
+ // Year match
435
+ if (seasonYear && subYear === seasonYear) score += 50;
436
+ else if (showYear && subYear === showYear && season === 1) score += 30;
437
+ // Any series result is better than none
438
+ if (score === 0) score += 10;
439
+ }
440
 
441
+ // Also score by slug patterns (backup)
442
+ if (ord && slugLow.includes(`-${ord}-season`)) score += Math.max(score, 80);
443
+ if (slugLow.includes(`-season-${season}-`)) score += Math.max(score, 70);
444
+ if (slugLow.includes(`-s${String(season).padStart(2,'0')}`)) score += Math.max(score, 60);
445
+ if (ord && slugLow.includes(`-${ord}season`)) score += Math.max(score, 50);
446
 
447
+ if (score > 0) results.push({ slug, score });
448
+ });
 
449
 
450
+ if (!results.length) return null;
451
+ results.sort((a, b) => b.score - a.score);
 
452
 
453
+ for (const r of results.slice(0, 8)) {
454
+ await sleep(180);
455
+ if (await _probeSlug(r.slug, { season })) return r.slug;
456
+ }
457
+ return null;
458
  }
459
 
460
+ async function _smartPatternSlugs(title, season, seasonYear, showYear) {
 
 
 
 
461
  const base = titleToSlug(title, null);
462
  const ord = ORDINALS[season] || '';
463
  const pad = String(season).padStart(2, '0');
464
  const patterns = [];
465
 
466
+ // Tier 1 โ€” TMDB season year exact
467
  if (seasonYear) {
468
  if (ord) {
469
  patterns.push(`${base}-${ord}-season-${seasonYear}`);
470
+ patterns.push(`${base}--${ord}-season-${seasonYear}`);
471
  patterns.push(`${base}-${ord}-season-tv-${seasonYear}`);
472
+ patterns.push(`${base}-the-${ord}-season-${seasonYear}`);
473
  }
474
  patterns.push(`${base}-season-${season}-${seasonYear}`);
475
  patterns.push(`${base}-s${pad}-${seasonYear}`);
476
+ patterns.push(`${base}-${seasonYear}`);
477
  }
478
 
479
+ // Tier 2 โ€” no year
480
  if (ord) {
481
  patterns.push(`${base}-${ord}-season`);
482
  patterns.push(`${base}--${ord}-season`);
483
+ patterns.push(`${base}-the-${ord}-season`);
484
+ patterns.push(`${base}-${ord}-series`);
485
  }
486
  patterns.push(`${base}-season-${season}`);
487
  patterns.push(`${base}-s${pad}`);
 
488
 
489
+ // Tier 3 โ€” adjacent years
490
  const yearBase = seasonYear ? +seasonYear : (showYear ? +showYear : null);
491
  if (yearBase) {
492
+ for (const yr of [yearBase - 1, yearBase + 1, yearBase - 2]) {
493
+ if (ord) {
494
+ patterns.push(`${base}-${ord}-season-${yr}`);
495
+ patterns.push(`${base}-the-${ord}-season-${yr}`);
496
+ }
497
  patterns.push(`${base}-season-${season}-${yr}`);
498
  patterns.push(`${base}-${yr}`);
499
  }
500
  }
501
 
 
 
 
 
 
502
  const unique = [...new Set(patterns)].filter(Boolean);
503
+ console.log(`[slug] Probing ${unique.length} patterns for "${title}" S${season}`);
504
 
505
  for (const s of unique) {
506
  await sleep(120);
507
+ if (await _probeSlug(s, { season })) {
508
+ console.log(`[slug] HIT: ${s}`);
509
  return s;
510
  }
511
  }
512
  return null;
513
  }
514
 
515
+ // ================================================================
516
+ // MOVIE SLUG RESOLUTION
517
+ // ================================================================
518
+ async function resolveMovieSlug(imdbId, titleHint, yearHint) {
519
+ const ck = `mslug2:${imdbId}`;
520
+ const hit = cacheGet(ck);
521
+ if (hit !== null) return hit;
522
+
523
+ const tmdb = await getTMDB(imdbId);
524
+ const title = tmdb?.title || titleHint;
525
+ const year = tmdb?.year || yearHint;
 
 
 
 
 
 
 
526
 
527
+ // Strategy 1: Direct slug (most common pattern)
528
+ if (title && year) {
529
+ const direct = titleToSlug(title, year);
530
+ if (await _probeMovieSlug(direct, title)) { cacheSet(ck, direct); return direct; }
531
+ }
532
+
533
+ // Strategy 2: Search by IMDb ID
534
+ let slug = await _searchMovieSlug(imdbId, year);
535
+ if (slug && await _probeMovieSlug(slug, title)) { cacheSet(ck, slug); return slug; }
536
+
537
+ // Strategy 3: Search by title
538
+ if (title) {
539
+ slug = await _searchMovieSlug(title, year);
540
+ if (slug && await _probeMovieSlug(slug, title)) { cacheSet(ck, slug); return slug; }
541
+
542
+ // Strategy 4: Brute-force slugs
543
+ const candidates = [];
544
+ if (year) {
545
+ candidates.push(titleToSlug(title, year));
546
+ candidates.push(titleToSlug(title, String(+year - 1)));
547
+ candidates.push(titleToSlug(title, String(+year + 1)));
548
+ }
549
+ candidates.push(titleToSlug(title, null));
550
+ for (const c of [...new Set(candidates)]) {
551
+ await sleep(250);
552
+ if (await _probeMovieSlug(c, title)) { cacheSet(ck, c); return c; }
553
+ }
554
+ }
555
+
556
+ cacheSet(ck, null);
557
+ return null;
558
+ }
559
+
560
+ async function _searchMovieSlug(query, yearHint) {
561
+ const url = `${BASE}/subtitles/searchbytitle?query=${encodeURIComponent(query)}&l=`;
562
+ const html = await getHTML(url);
563
+ if (!html) return null;
564
+ const $ = cheerio.load(html);
565
+ const results = [];
566
+ const SELS = ['ul.search-result li','.search-result li','.content ul li','section ul li','ul li','li'];
567
+ let found = false;
568
+
569
+ for (const sel of SELS) {
570
+ const items = $(sel);
571
+ if (!items.length) continue;
572
+ items.each((_, el) => {
573
+ const a = $(el).find('a[href]').first();
574
+ const href = a.attr('href') || '';
575
+ if (!href.startsWith('/subtitles/') || href === '/subtitles/searchbytitle') return;
576
  const parts = href.split('/').filter(Boolean);
577
  if (parts.length < 2) return;
578
  const slug = parts[1];
579
+ if (!slug || slug === 'searchbytitle') return;
580
+ const text = _clean(a.text());
581
+ const yrMatch = text.match(/\((\d{4})\)/);
582
+ const resYear = yrMatch ? +yrMatch[1] : null;
583
+ let score = 1;
584
+ if (query.startsWith('tt')) score += 100;
585
+ if (yearHint && resYear === +yearHint) score += 10;
586
+ // Penalise series results in movie search
587
+ if (text.toLowerCase().includes('season')) score -= 5;
588
+ results.push({ slug, score });
589
+ found = true;
 
590
  });
591
+ if (found) break;
592
+ }
593
 
594
+ if (!results.length) return null;
595
+ results.sort((a, b) => b.score - a.score);
596
+ return results[0].slug;
597
+ }
 
 
 
 
 
 
 
 
 
598
 
599
+ async function _probeMovieSlug(slug, expectedTitle) {
600
+ if (!slug) return false;
601
+ try {
602
+ const html = await getHTML(`${BASE}/subtitles/${slug}/english`);
603
+ if (!html || html.length < 1500) return false;
604
+ if (expectedTitle) {
605
+ const $ = cheerio.load(html);
606
+ const pageTitle = ($('title').text() || '').toLowerCase();
607
+ const exp = expectedTitle.toLowerCase();
608
+ // Accept if title appears in page title OR if there are subtitle items
609
+ return pageTitle.includes(exp) || html.includes('class="item"') || html.includes('class="r"');
610
  }
611
+ return true;
612
+ } catch (_) { return false; }
613
  }
 
614
 
615
+ // ================================================================
616
+ // SUBTITLE SCRAPING
617
+ // ================================================================
618
+ async function getSubsList(slug, lang, season, episode, streamTokens) {
619
  if (!ALLOWED_LANGS.has(lang)) return [];
620
  const ck = `list2:${slug}:${lang}:${episode || 0}`;
621
  const hit = cacheGet(ck);
 
625
  const html = await getHTML(url);
626
  if (!html) { cacheSet(ck, []); return []; }
627
  const $ = cheerio.load(html);
628
+ let raw = [];
629
+
630
+ // Primary scraper
631
  $('li.item').each((_, el) => { _extractItem($, el, lang, raw); });
632
 
633
+ // Fallback 1: table rows
634
+ if (!raw.length) {
635
+ $('tr, .subtitle-item, .item').each((_, el) => { _extractItem($, el, lang, raw); });
636
+ }
637
+
638
+ // Fallback 2: any anchor with matching lang path
639
  if (!raw.length) {
 
640
  $('a[href]').each((_, a) => {
641
  const href = $(a).attr('href') || '';
642
  if (!href.includes(`/${lang}/`) || !/\/\d+$/.test(href)) return;
643
  const parts = href.split('/');
644
  const subId = parts[parts.length - 1];
645
  if (!subId || !/^\d+$/.test(subId)) return;
646
+ const full = href.startsWith('http') ? href : `${BASE}${href}`;
647
+ raw.push({ subId, detailUrl: full, isGood: false, releaseInfo: '', comment: '', authorNote: '', lang, goodVotes: 0, badVotes: 0, downloads: 0, _rawText: '' });
 
 
 
648
  });
649
  }
650
 
651
+ // Filter to exact episode if possible
652
+ if (season && episode && raw.length) {
653
  const expected = `s${String(season).padStart(2,'0')}e${String(episode).padStart(2,'0')}`;
654
  const filtered = raw.filter(item => {
655
  const txt = (item._rawText || item.releaseInfo || '').toLowerCase();
656
  return txt.includes(expected);
657
  });
658
  if (filtered.length) raw = filtered;
 
659
  }
660
 
661
+ // Deduplicate
662
  const seen = new Set();
663
  const unique = raw.filter(s => { if (seen.has(s.subId)) return false; seen.add(s.subId); return true; });
664
+
665
+ const toEnrich = unique.slice(0, MAX_DETAIL);
666
+ const enriched = (await Promise.all(toEnrich.map(s => _enrichSub(s)))).filter(Boolean);
667
  const human = enriched.filter(s => !isAISub(`${s.releaseInfo} ${s.comment} ${s.authorNote}`));
668
+
669
  cacheSet(ck, human);
670
  return human;
671
  }
 
674
  const $el = $(el);
675
  const rawText = _clean($el.text());
676
  let detailHref = null;
677
+
678
  $el.find('a').each((_, a) => {
679
  const h = $(a).attr('href') || '';
680
  if (h.includes(`/${lang}/`) && /\/\d+$/.test(h)) detailHref = h;
681
  });
682
+
683
  if (!detailHref) return;
684
  const hLow = detailHref.toLowerCase();
685
  if (!hLow.includes(`/${lang}/`)) return;
686
  if (OTHER_LANGS.some(ol => hLow.includes(`/${ol}/`))) return;
687
+
688
  const parts = detailHref.split('/');
689
  const subId = parts[parts.length - 1];
690
  if (!subId || !/^\d+$/.test(subId)) return;
691
+
692
  const isGood = (
693
  $el.find('.green-icon, .positive-icon').length > 0 ||
694
  $el.find('img[alt="good"], img[alt="Positive"], img[alt="positive"]').length > 0 ||
695
  $el.hasClass('good') || $el.hasClass('positive') ||
696
  ($el.attr('class') || '').toLowerCase().includes('good')
697
  );
698
+
699
  const comment = _clean($el.find('p.comment, .comment').first().text());
700
  const releaseLines = $el.find('.r li, ul.release li, ul.scrolllist li, ul li')
701
+ .map((_, li) => $(li).text().trim()).get().filter(Boolean);
702
+ const releaseInfo = (releaseLines.join(' | ') || _clean($el.text())).substring(0, 130);
703
+
704
  if (isAISub(`${comment} ${releaseInfo}`)) return;
705
+
706
  out.push({
707
  subId,
708
+ detailUrl: detailHref.startsWith('http') ? detailHref : `${BASE}${detailHref}`,
709
  isGood, releaseInfo, comment, authorNote: '', lang,
710
+ goodVotes: 0, badVotes: 0, downloads: 0, _rawText: rawText,
711
  });
712
  }
713
 
 
715
  const ck = `det2:${sub.subId}`;
716
  const hit = cacheGet(ck);
717
  if (hit !== null) { if (hit.__aiBlocked) return null; return { ...sub, ...hit }; }
718
+
719
  const html = await getHTML(sub.detailUrl);
720
  if (!html) return sub;
721
+ const $ = cheerio.load(html);
722
+ const txt = $.root().text();
723
+ const goodVotes = +(txt.match(/voted\s+as\s+good\s+by[:\s]*(\d+)/i)?.[1] ?? 0);
724
+ const badVotes = +(txt.match(/voted\s+as\s+bad\s+by[:\s]*(\d+)/i)?.[1] ?? 0);
725
+ const downloads = +(txt.match(/downloads?[:\s]*(\d+)/i)?.[1] ?? 0);
726
+ const total = goodVotes + badVotes;
727
+ const ratio = total >= 3 ? goodVotes / total : null;
728
+ const isGood = sub.isGood || (ratio !== null && ratio >= 0.70 && goodVotes >= 2);
729
  const authorNote = _clean($('p.comment, .comment, .notes, .author-comment').first().text());
730
+
731
+ if (isAISub(`${authorNote} ${sub.releaseInfo} ${sub.comment}`)) {
732
+ cacheSet(ck, { __aiBlocked: true });
733
+ return null;
734
+ }
735
+
736
  const relLines = $('ul.release li, .release-names li, ul.scrolllist li').map((_, e) => $(e).text().trim()).get();
737
  const releaseInfo = relLines.length ? relLines.join(' | ').substring(0, 130) : sub.releaseInfo;
738
+
739
+ const enriched = { ...sub, releaseInfo, goodVotes, badVotes, downloads, isGood, authorNote };
740
+ cacheSet(ck, enriched);
741
+ return enriched;
742
  }
743
 
744
+ // ================================================================
745
+ // DOWNLOAD & SRT EXTRACTION
746
+ // ================================================================
747
  async function fetchSRT(downloadUrl) {
748
  const ck = `srt2:${downloadUrl}`;
749
  const hit = cacheGet(ck);
750
  if (hit !== null) return hit;
751
  try {
752
+ const buf = await getBinary(downloadUrl);
 
 
 
 
 
 
 
 
 
 
 
 
 
753
  if (!buf) return null;
754
  const content = _extractFromZip(buf) ?? _tryDirect(buf);
755
  if (!content) return null;
756
  cacheSet(ck, content);
757
  return content;
758
+ } catch (_) { return null; }
759
  }
760
 
761
  function _extractFromZip(buf) {
 
769
  })
770
  .sort((a, b) => (RANK[_ext(a.entryName)] ?? 9) - (RANK[_ext(b.entryName)] ?? 9));
771
  for (const f of files) {
772
+ const raw = zip.readFile(f);
773
  if (!raw) continue;
774
  const text = _decode(raw);
775
  if (_isSub(text)) return text;
 
777
  } catch (_) {}
778
  return null;
779
  }
780
+
781
  function _tryDirect(buf) { const t = _decode(buf); return _isSub(t) ? t : null; }
782
  const _ext = n => (n.match(/(\.[^.]+)$/)?.[1]?.toLowerCase() ?? '');
783
  const _isSub = t => typeof t === 'string' && t.includes('-->') && t.length > 50;
 
788
  return buf.toString('latin1');
789
  }
790
 
791
+ // ================================================================
792
+ // SORT: index [0] = best subtitle
793
+ // Movies: sort by downloads desc (most popular = best sync).
794
+ // Series: sort by scoreSubtitle desc (exact S##E## + group match).
795
+ // ================================================================
796
+ function sortForBest(subs, streamTokens, type) {
797
+ if (type === 'series' && streamTokens) {
798
+ return subs
799
+ .map(s => ({ ...s, _score: scoreSubtitle(s, streamTokens) }))
800
+ .sort((a, b) => {
801
+ if (b._score !== a._score) return b._score - a._score;
802
+ if (b.isGood !== a.isGood) return b.isGood ? 1 : -1;
803
+ return b.downloads - a.downloads;
804
+ });
805
+ }
806
+ // Movies: isGood first, then downloads
807
+ return subs.slice().sort((a, b) => {
808
+ if (b.isGood !== a.isGood) return b.isGood ? 1 : -1;
809
+ return b.downloads - a.downloads;
810
+ });
811
+ }
812
+
813
+ // ================================================================
814
+ // ROUTES
815
+ // ================================================================
816
  app.get('/manifest.json', (req, res) => res.json({
817
+ id : 'org.van.vansubf2mplus.v2',
818
+ name : 'VanSubF2M+',
819
+ description: 'Human-Verified Arabic & English subtitles. Anti-AI. Enhanced 100% coverage.',
820
+ version : '2.0.0',
821
  resources : ['subtitles'],
822
  types : ['movie', 'series'],
823
  idPrefixes : ['tt'],
824
+ catalogs : [],
825
  }));
826
 
827
  app.get('/sub/:enc.srt', async (req, res) => {
 
831
  if (!dlUrl.startsWith('https://subf2m.co/') || !dlUrl.includes('/subtitles/') || !dlUrl.endsWith('/download'))
832
  return res.status(403).end('Forbidden');
833
  const content = await fetchSRT(dlUrl);
834
+ if (!content) return res.status(404).end('Not found');
835
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
836
+ res.setHeader('Cache-Control', 'public, max-age=86400');
837
  res.setHeader('Content-Disposition', 'inline; filename="subtitle.srt"');
838
  res.end(content);
839
  });
 
843
  const { type, id } = req.params;
844
  if (!id?.startsWith('tt')) return res.json({ subtitles: [] });
845
 
 
846
  const ck = `result2:${id}`;
847
  const hit = cacheGet(ck);
848
  if (hit) return res.json({ subtitles: hit });
 
865
  slug = await resolveSeriesSlug(imdbId, season);
866
  } else {
867
  const tmdb = await getTMDB(imdbId);
868
+ slug = await resolveMovieSlug(imdbId, tmdb?.title || null, tmdb?.year || null);
869
  }
870
 
871
  if (!slug) return res.json({ subtitles: [] });
872
 
873
  const [engRes, araRes] = await Promise.allSettled([
874
+ getSubsList(slug, 'english', season, episode, streamTokens),
875
+ getSubsList(slug, 'arabic', season, episode, streamTokens),
876
  ]);
877
 
878
+ let engSubs = (engRes.status === 'fulfilled' ? engRes.value : []);
879
+ let araSubs = (araRes.status === 'fulfilled' ? araRes.value : []);
 
 
 
 
 
 
880
 
881
+ // Sort so [0] = best, then cap at MAX_PER_LANG
882
+ engSubs = sortForBest(engSubs, streamTokens, type).slice(0, MAX_PER_LANG);
883
+ araSubs = sortForBest(araSubs, streamTokens, type).slice(0, MAX_PER_LANG);
884
 
885
  const proto = req.headers['x-forwarded-proto'] || (req.socket?.encrypted ? 'https' : 'http');
886
  const host = req.headers['x-forwarded-host'] || req.headers.host;
887
  const origin = `${proto}://${host}`;
888
 
889
  const build = (subs, langCode, flag) => subs.map(s => {
890
+ const dlUrl = `${s.detailUrl}/download`;
891
+ const enc = Buffer.from(dlUrl).toString('base64url');
892
+ const badge = s.isGood ? ' โญ' : '';
893
+ const votes = s.goodVotes > 0 ? ` [๐Ÿ‘${s.goodVotes}]` : '';
894
+ const dls = s.downloads > 0 ? ` [โฌ‡${s.downloads}]` : '';
895
+ const name = `${flag}${badge}${votes}${dls} ${s.releaseInfo}`.trim().substring(0, 100);
896
+ return { id: `vsub-${langCode}-${s.subId}`, url: `${origin}/sub/${enc}.srt`, lang: langCode, name };
897
  });
898
 
899
+ const subtitles = [...build(engSubs, 'eng', '๐Ÿ‡บ๐Ÿ‡ธ English'), ...build(araSubs, 'ara', '๐Ÿ‡ธ๐Ÿ‡ฆ Arabic')];
900
  cacheSet(ck, subtitles);
901
  res.json({ subtitles });
902
  } catch (e) {
903
+ console.error('[route]', e.message);
904
  res.json({ subtitles: [] });
905
  }
906
  });
907
 
908
+ app.listen(PORT, '0.0.0.0', () =>