vanseleem commited on
Commit
cd4bb7c
Β·
verified Β·
1 Parent(s): 92b4dbf

Update index.js

Browse files
Files changed (1) hide show
  1. index.js +88 -311
index.js CHANGED
@@ -12,7 +12,7 @@ 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);
@@ -21,13 +21,33 @@ app.use((req, res, next) => {
21
 
22
  // ── Config ───────────────────────────────────────────────────────
23
  const BASE = 'https://subf2m.co';
24
- const TMDB_KEY = '30166e2db6b420d3f230808cd1bb2c90';
25
- const TMDB_BASE = 'https://api.themoviedb.org/3';
26
- const MAX_PER_LANG = 4; // final output per language
27
  const CACHE_TTL = 6 * 60 * 60 * 1000;
28
  const REQ_TIMEOUT = 15_000;
29
  const DL_TIMEOUT = 25_000;
30
- const MAX_DETAIL = 50; // how many subtitles to deep‑scan per page
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  // ── Globals ───────────────────────────────────────────────────────
33
  const cache = new Map();
@@ -125,268 +145,62 @@ function cacheSet(key, val) {
125
  cache.set(key, { val, ts: Date.now() });
126
  }
127
 
128
- // ── TMDB ───────────────────────────────────────────────────────────
129
- async function getTMDB(imdbId) {
130
- const ck = `tmdb:${imdbId}`;
131
  const hit = cacheGet(ck);
132
  if (hit !== null) return hit;
133
- try {
134
- const raw = await _request(`${TMDB_BASE}/find/${imdbId}?external_source=imdb_id&api_key=${TMDB_KEY}`, { timeout: 8000 });
135
- if (!raw) { cacheSet(ck, null); return null; }
136
- const d = JSON.parse(raw);
137
- let info = null;
138
- if (d.movie_results?.length) {
139
- const m = d.movie_results[0];
140
- info = { title: m.title || m.original_title, year: m.release_date?.substring(0,4), type: 'movie', tmdbId: m.id };
141
- } else if (d.tv_results?.length) {
142
- const t = d.tv_results[0];
143
- info = { title: t.name || t.original_name, year: t.first_air_date?.substring(0,4), type: 'series', tmdbId: t.id };
144
- }
145
- cacheSet(ck, info);
146
- return info;
147
- } catch { cacheSet(ck, null); return null; }
148
- }
149
-
150
- async function getTMDBSeasonYear(tmdbId, season) {
151
- if (!tmdbId) return null;
152
- const ck = `tmdbseason:${tmdbId}:${season}`;
153
- const hit = cacheGet(ck);
154
- if (hit !== null) return hit;
155
- try {
156
- const raw = await _request(`${TMDB_BASE}/tv/${tmdbId}/season/${season}?api_key=${TMDB_KEY}`, { timeout: 8000 });
157
- if (!raw) { cacheSet(ck, null); return null; }
158
- const d = JSON.parse(raw);
159
- const year = d.air_date?.substring(0,4) || null;
160
- cacheSet(ck, year);
161
- return year;
162
- } catch { cacheSet(ck, null); return null; }
163
- }
164
-
165
- // ── Slug utilities ─────────────────────────────────────────────────
166
- function titleToSlug(title, year) {
167
- const base = (title || '').toLowerCase()
168
- .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
169
- .replace(/[^a-z0-9\s]/g, '')
170
- .replace(/\s+/g, '-')
171
- .replace(/-+/g, '-')
172
- .replace(/^-|-$/g, '');
173
- return year ? `${base}-${year}` : base;
174
- }
175
 
176
- // ── Language & AI filters ─��────────────────────────────────────────
177
- const ALLOWED_LANGS = new Set(['english', 'arabic']);
178
-
179
- const AI_PATTERNS = [
180
- /\ba\.i\.\b/i, /\bai[\s\-_]sub(title)?s?\b/i, /\bai[\s\-_](translat|generat|correct|made|creat)/i,
181
- /(translat|generat|creat|made|sub(title)?s?)\s+(by\s+)?ai\b/i, /machine[\s\-_]translat/i, /auto[\s\-_]?translat/i,
182
- /auto[\s\-_]?generat/i, /auto[\s\-_]?sub(title)?s?\b/i, /\bautosub\b/i, /\bchatgpt\b/i, /\bgpt[\s\-_]?[34o]?\b/i,
183
- /\bdeep[\s\-_]?l\b/i, /google[\s\-_]translat/i, /yandex[\s\-_]translat/i, /amazon[\s\-_]translat/i,
184
- /microsoft[\s\-_]translat/i, /bing[\s\-_]translat/i, /\bopenai\b/i, /\bwhisper\b/i, /\bgemini\b/i, /\bclaude\b/i,
185
- /\bllama\b/i, /neural[\s\-_](translat|sub|net|machine)/i, /\bnmt\b/i, /artificial[\s\-_]intelligence/i,
186
- /computer[\s\-_](translat|generat)/i,
187
- ];
188
-
189
- function isAISub(text) {
190
- return AI_PATTERNS.some(rx => rx.test(text || ''));
191
- }
192
-
193
- // ── Episode parsing from filename ──────────────────────────────────
194
- function seasonEpisodeFromFilename(text) {
195
- if (!text) return { season: null, episode: null };
196
- const n = text.toLowerCase();
197
- const m1 = n.match(/s(\d{1,2})[\.\-_\s]?e(\d{1,3})/);
198
- if (m1) return { season: parseInt(m1[1], 10), episode: parseInt(m1[2], 10) };
199
- const m2 = n.match(/[._\-\s](\d{1,2})x(\d{1,3})[._\-\s]/);
200
- if (m2) return { season: parseInt(m2[1], 10), episode: parseInt(m2[2], 10) };
201
- return { season: null, episode: null };
202
- }
203
-
204
- // ── Slug resolution ────────────────────────────────────────────────
205
- async function _probeSlug(slug, expectedTitle) {
206
- if (!slug) return false;
207
- try {
208
- const html = await getHTML(`${BASE}/subtitles/${slug}/english`);
209
- if (!html || html.length < 2000) return false;
210
- if (expectedTitle) {
211
- const $ = cheerio.load(html);
212
- const pageTitle = ($('title').text() || '').toLowerCase();
213
- return pageTitle.includes(expectedTitle.toLowerCase());
214
- }
215
- return true;
216
- } catch { return false; }
217
- }
218
-
219
- async function _searchSlug(query, yearHint) {
220
- const url = `${BASE}/subtitles/searchbytitle?query=${encodeURIComponent(query)}&l=`;
221
  const html = await getHTML(url);
222
- if (!html) return null;
 
223
  const $ = cheerio.load(html);
224
  const results = [];
225
- $('a[href*="/subtitles/"]').each((_, a) => {
226
- const href = $(a).attr('href');
227
- if (!href || href === '/subtitles/searchbytitle') return;
 
 
228
  const parts = href.split('/').filter(Boolean);
229
  if (parts.length < 2) return;
230
  const slug = parts[1];
231
- if (!slug || slug === 'searchbytitle') return;
232
- const text = $(a).text().trim();
233
- const yrMatch = text.match(/\((\d{4})\)/);
234
- const resYear = yrMatch ? +yrMatch[1] : null;
235
- let score = 1;
236
- if (query.startsWith('tt')) score += 100;
237
- if (yearHint && resYear === +yearHint) score += 10;
238
- results.push({ slug, score });
239
  });
240
- if (!results.length) return null;
241
- results.sort((a, b) => b.score - a.score);
242
- return results[0].slug;
243
- }
244
-
245
- async function resolveMovieSlug(imdbId, titleHint, yearHint) {
246
- const ck = `mslug:${imdbId}`;
247
- const hit = cacheGet(ck);
248
- if (hit !== null) return hit;
249
-
250
- const tmdb = await getTMDB(imdbId);
251
- const title = tmdb?.title || titleHint;
252
- const year = tmdb?.year || yearHint;
253
-
254
- let slug = await _searchSlug(imdbId, yearHint);
255
- if (slug && await _probeSlug(slug, title)) { cacheSet(ck, slug); return slug; }
256
 
257
- if (title) {
258
- slug = await _searchSlug(title, year);
259
- if (slug && await _probeSlug(slug, title)) { cacheSet(ck, slug); return slug; }
260
-
261
- const candidates = [];
262
- if (year) candidates.push(titleToSlug(title, year));
263
- candidates.push(titleToSlug(title, null));
264
- if (year) {
265
- candidates.push(titleToSlug(title, String(+year - 1)));
266
- candidates.push(titleToSlug(title, String(+year + 1)));
267
- }
268
- for (const c of candidates) {
269
- await sleep(300);
270
- if (await _probeSlug(c, title)) { cacheSet(ck, c); return c; }
271
- }
272
  }
273
- cacheSet(ck, null);
274
- return null;
275
- }
276
-
277
- const SEASON_WORDS = [
278
- '', 'first', 'second', 'third', 'fourth', 'fifth',
279
- 'sixth', 'seventh', 'eighth', 'ninth', 'tenth',
280
- 'eleventh', 'twelfth', 'thirteenth', 'fourteenth', 'fifteenth'
281
- ];
282
-
283
- async function _probeSeriesSlugFast(slug, season) {
284
- if (!slug || slug.length > 200) return false;
285
- try {
286
- const html = await getHTML(`${BASE}/subtitles/${slug}/english`, 1);
287
- if (!html || html.length < 800) return false;
288
- const s2 = String(season).padStart(2, '0');
289
- return html.includes(`S${s2}E`) || html.includes(`s${s2}e`) ||
290
- html.includes(`Season ${season}`) || html.includes(`season ${season}`) ||
291
- new RegExp(`\\bs${s2}e\\d`, 'i').test(html);
292
- } catch { return false; }
293
- }
294
-
295
- async function resolveSeriesSlug(imdbId, season) {
296
- const ck = `sslug:${imdbId}:${season}`;
297
- const hit = cacheGet(ck);
298
- if (hit !== null) return hit;
299
-
300
- const tmdb = await getTMDB(imdbId);
301
- const title = tmdb?.title;
302
- if (!title) { cacheSet(ck, null); return null; }
303
-
304
- // 1) TMDB-informed patterns
305
- const seasonYear = tmdb?.tmdbId ? await getTMDBSeasonYear(tmdb.tmdbId, season) : null;
306
- let slug = await _smartPatterns(title, season, seasonYear, tmdb?.year);
307
- if (slug) { cacheSet(ck, slug); return slug; }
308
 
309
- // 2) Site search
310
- slug = await _searchSeriesSlug(title, season);
311
- if (slug) { cacheSet(ck, slug); return slug; }
312
 
313
- // 3) Bare title page (all seasons)
314
- const baseSlug = titleToSlug(title, null);
315
- if (await _probeSeriesSlugFast(baseSlug, season)) { cacheSet(ck, baseSlug); return baseSlug; }
316
-
317
- cacheSet(ck, null);
318
- return null;
319
- }
320
-
321
- async function _smartPatterns(title, season, seasonYear, showYear) {
322
- const base = titleToSlug(title, null);
323
- const ord = SEASON_WORDS[season] || '';
324
- const pad = String(season).padStart(2, '0');
325
- const patterns = [];
326
-
327
- if (seasonYear) {
328
- if (ord) {
329
- patterns.push(`${base}-${ord}-season-${seasonYear}`);
330
- patterns.push(`${base}--${ord}-season-${seasonYear}`);
331
- patterns.push(`${base}-${ord}-season-tv-${seasonYear}`);
332
- }
333
- patterns.push(`${base}-season-${season}-${seasonYear}`);
334
- patterns.push(`${base}-s${pad}-${seasonYear}`);
335
- patterns.push(`${base}-${seasonYear}`);
336
  }
337
 
338
- if (ord) {
339
- patterns.push(`${base}-${ord}-season`);
340
- patterns.push(`${base}--${ord}-season`);
341
- }
342
- patterns.push(`${base}-season-${season}`);
343
- patterns.push(`${base}-s${pad}`);
344
-
345
- const yearBase = seasonYear ? +seasonYear : (showYear ? +showYear : null);
346
- if (yearBase) {
347
- for (const yr of [yearBase - 1, yearBase + 1]) {
348
- if (ord) patterns.push(`${base}-${ord}-season-${yr}`);
349
- patterns.push(`${base}-season-${season}-${yr}`);
350
- patterns.push(`${base}-${yr}`);
351
  }
352
  }
353
 
354
- const unique = [...new Set(patterns)].filter(Boolean);
355
- for (const s of unique) {
356
- await sleep(150);
357
- if (await _probeSeriesSlugFast(s, season)) return s;
358
- }
359
- return null;
360
- }
361
-
362
- async function _searchSeriesSlug(title, season) {
363
- const url = `${BASE}/subtitles/searchbytitle?query=${encodeURIComponent(title)}&l=`;
364
- const html = await getHTML(url);
365
- if (!html) return null;
366
- const $ = cheerio.load(html);
367
- const firstWord = titleToSlug(title, null).split('-')[0];
368
- const results = [];
369
- $('a[href*="/subtitles/"]').each((_, a) => {
370
- const href = $(a).attr('href');
371
- if (!href || href === '/subtitles/searchbytitle') return;
372
- const parts = href.split('/').filter(Boolean);
373
- if (parts.length < 2) return;
374
- const slug = parts[1];
375
- if (!slug || slug === 'searchbytitle') return;
376
- const slugLow = slug.toLowerCase();
377
- if (!slugLow.startsWith(firstWord)) return;
378
- let score = 0;
379
- if (SEASON_WORDS[season] && slugLow.includes(`${SEASON_WORDS[season]}-season`)) score += 100;
380
- if (slugLow.includes(`season-${season}`)) score += 90;
381
- if (slugLow.includes(`-s${String(season).padStart(2,'0')}`)) score += 80;
382
- if (score > 0) results.push({ slug, score });
383
- });
384
- results.sort((a, b) => b.score - a.score);
385
- for (const r of results.slice(0, 5)) {
386
- await sleep(200);
387
- if (await _probeSeriesSlugFast(r.slug, season)) return r.slug;
388
- }
389
- return null;
390
  }
391
 
392
  // ── Subtitle scraping ──────────────────────────────────────────────
@@ -461,7 +275,6 @@ async function getSubsList(slug, lang) {
461
  });
462
 
463
  if (!raw.length) {
464
- // fallback: any link to a subtitle detail page
465
  $('a[href]').each((_, a) => {
466
  const href = $(a).attr('href') || '';
467
  if (!href.includes(`/${lang}/`) || !/\/\d+$/.test(href)) return;
@@ -480,47 +293,18 @@ async function getSubsList(slug, lang) {
480
  return enriched;
481
  }
482
 
483
- // ── Scoring (purely logical) ───────────────────────────────────────
484
  const OFFICIAL_SOURCES = ['netflix','nf','amzn','amazon','osn','shahid','wavo','hbo','hulu','disney','dsnp','appletv','atvp'];
485
 
486
- function scoreTrack(sub, streamTitle, season, episode) {
487
  const text = (sub.releaseInfo || '').toLowerCase();
488
  let s = 0;
489
 
490
- // 1) Episode match
491
- if (season != null && episode != null) {
492
- const parsed = seasonEpisodeFromFilename(text);
493
- if (parsed.season !== null && parsed.episode !== null) {
494
- if (parsed.season === season && parsed.episode === episode) s += 300;
495
- else s -= 150; // explicitly wrong
496
- } else if (parsed.season !== null && parsed.season !== season) {
497
- s -= 150;
498
- }
499
- // no info β†’ neutral
500
- }
501
-
502
- // 2) Official streaming source
503
  if (OFFICIAL_SOURCES.some(src => text.includes(src))) s += 150;
504
-
505
- // 3) Sync keywords
506
- if (/\b(synced|resync|corrected|fixed|proper|repack|retail|perfect\.sync|sync\.fix)\b/i.test(text)) s += 80;
507
-
508
- // 4) Release group match between subtitle and stream filename
509
- const strmTitle = (streamTitle || '').toLowerCase();
510
- const subGroups = text.match(/\b(?:-([a-z0-9]{2,20}))\b/g) || [];
511
- for (const g of subGroups) {
512
- const group = g.replace(/^-/, '');
513
- if (group && strmTitle.includes(group)) { s += 100; break; }
514
- }
515
-
516
- // 5) Hearing impaired penalty
517
  if (/\b(sdh|hi\.|hi_|hearing.impaired)\b/i.test(text) || sub.hearingImpaired) s -= 40;
518
-
519
- // 6) Community rating
520
  if (sub.rating) s += Math.round((sub.rating || 0) * 4);
521
-
522
- // 7) ASS/SSA bonus (styling)
523
- if (/\.(ass|ssa)$/i.test(text)) s += 5;
524
 
525
  return s;
526
  }
@@ -574,10 +358,10 @@ async function resolveUrl(downloadUrl, lang, season, episode) {
574
  const BASE_URL = process.env.SPACE_HOST ? `https://${process.env.SPACE_HOST}` : `http://localhost:${PORT}`;
575
 
576
  app.get('/manifest.json', (req, res) => res.json({
577
- id: 'org.van.vansubs.v1',
578
- name: 'VanSubs+ (Subf2m)',
579
- description: 'Human-verified Arabic & English. No AI. Streaming priority.',
580
- version: '2.0.0',
581
  resources: ['subtitles'],
582
  types: ['movie', 'series'],
583
  idPrefixes: ['tt'],
@@ -614,19 +398,14 @@ app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
614
  const extra = req.params.extra ? decodeURIComponent(req.params.extra.replace('.json','')) : '';
615
  const streamTitle = (extra.match(/filename=([^&]+)/i) || [])[1] || '';
616
 
617
- console.log(`\n╔═ [Subf2m v2] ${type.toUpperCase()} ${imdbId}` +
618
  (season ? ` S${String(season).padStart(2,'0')}E${String(episode??0).padStart(2,'0')}` : '') +
619
  (streamTitle ? `\nβ•‘ hint: "${streamTitle}"` : ''));
620
 
621
- // --- slug ---
622
- let slug;
623
- if (type === 'series' && season) {
624
- slug = await resolveSeriesSlug(imdbId, season);
625
- } else {
626
- const tmdb = await getTMDB(imdbId);
627
- slug = await resolveMovieSlug(imdbId, tmdb?.title || null, tmdb?.year || null);
628
- }
629
  if (!slug) return res.json({ subtitles: [] });
 
630
 
631
  // --- fetch subtitles ---
632
  const [engSubs, araSubs] = await Promise.all([
@@ -634,30 +413,28 @@ app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
634
  getSubsList(slug, 'arabic')
635
  ]);
636
 
637
- // --- filter by episode if series ---
638
  let engFiltered = engSubs, araFiltered = araSubs;
639
  if (type === 'series' && season && episode) {
640
  const expected = `s${String(season).padStart(2,'0')}e${String(episode).padStart(2,'0')}`;
641
- engFiltered = engSubs.filter(s => (s.releaseInfo||'').toLowerCase().includes(expected));
642
- araFiltered = araSubs.filter(s => (s.releaseInfo||'').toLowerCase().includes(expected));
643
- if (!engFiltered.length && !araFiltered.length) {
644
- // fallback: keep all (season pack)
645
- engFiltered = engSubs; araFiltered = araSubs;
646
  }
647
  }
648
 
649
  // --- score & sort ---
650
- const scoreAndSort = (arr) => {
651
- return arr.map(s => ({
652
- ...s,
653
- score: scoreTrack(s, streamTitle, season, episode)
654
- }))
655
- .sort((a, b) => b.score - a.score)
656
- .slice(0, MAX_PER_LANG);
657
  };
658
 
659
- const engTop = scoreAndSort(engFiltered);
660
- const araTop = scoreAndSort(araFiltered);
661
 
662
  // --- resolve ZIPs in parallel ---
663
  const resolveAll = async (arr) => {
@@ -700,4 +477,4 @@ app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
700
  }
701
  });
702
 
703
- app.listen(PORT, '0.0.0.0', () => console.log(`\nβœ… Subf2m engine running β†’ http://0.0.0.0:${PORT}`));
 
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);
 
21
 
22
  // ── Config ───────────────────────────────────────────────────────
23
  const BASE = 'https://subf2m.co';
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_DETAIL = 50;
29
+
30
+ const SEASON_WORDS = [
31
+ 'first season', 'second season', 'third season', 'fourth season', 'fifth season',
32
+ 'sixth season', 'seventh season', 'eighth season', 'ninth season', 'tenth season',
33
+ 'eleventh season', 'twelfth season', 'thirteenth season', 'fourteenth season',
34
+ 'fifteenth season', 'sixteenth season'
35
+ ];
36
+
37
+ const ALLOWED_LANGS = new Set(['english', 'arabic']);
38
+
39
+ // ── AI patterns ──────────────────────────────────────────────────
40
+ const AI_PATTERNS = [
41
+ /\ba\.i\.\b/i, /\bai[\s\-_]sub(title)?s?\b/i, /\bai[\s\-_](translat|generat|correct|made|creat)/i,
42
+ /(translat|generat|creat|made|sub(title)?s?)\s+(by\s+)?ai\b/i, /machine[\s\-_]translat/i, /auto[\s\-_]?translat/i,
43
+ /auto[\s\-_]?generat/i, /auto[\s\-_]?sub(title)?s?\b/i, /\bautosub\b/i, /\bchatgpt\b/i, /\bgpt[\s\-_]?[34o]?\b/i,
44
+ /\bdeep[\s\-_]?l\b/i, /google[\s\-_]translat/i, /yandex[\s\-_]translat/i, /amazon[\s\-_]translat/i,
45
+ /microsoft[\s\-_]translat/i, /bing[\s\-_]translat/i, /\bopenai\b/i, /\bwhisper\b/i, /\bgemini\b/i, /\bclaude\b/i,
46
+ /\bllama\b/i, /neural[\s\-_](translat|sub|net|machine)/i, /\bnmt\b/i, /artificial[\s\-_]intelligence/i,
47
+ /computer[\s\-_](translat|generat)/i,
48
+ ];
49
+
50
+ function isAISub(text) { return AI_PATTERNS.some(rx => rx.test(text || '')); }
51
 
52
  // ── Globals ───────────────────────────────────────────────────────
53
  const cache = new Map();
 
145
  cache.set(key, { val, ts: Date.now() });
146
  }
147
 
148
+ // ── Slug resolution via IMDb ID (the whole trick) ─────────────────
149
+ async function resolveSlugByImdb(imdbId, season) {
150
+ const ck = `imdbslug:${imdbId}:${season ?? 'movie'}`;
151
  const hit = cacheGet(ck);
152
  if (hit !== null) return hit;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
+ const url = `${BASE}/subtitles/searchbytitle?query=${imdbId}&l=`;
155
+ console.log(`[IMDb] Searching: ${url}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  const html = await getHTML(url);
157
+ if (!html) { cacheSet(ck, null); return null; }
158
+
159
  const $ = cheerio.load(html);
160
  const results = [];
161
+
162
+ // Parse all search results
163
+ $('li div.title a').each((_, a) => {
164
+ const href = $(a).attr('href') || '';
165
+ if (!href.startsWith('/subtitles/')) return;
166
  const parts = href.split('/').filter(Boolean);
167
  if (parts.length < 2) return;
168
  const slug = parts[1];
169
+ const text = $(a).text().trim().toLowerCase();
170
+ results.push({ slug, text });
 
 
 
 
 
 
171
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
+ if (!results.length) {
174
+ console.log(`[IMDb] No results for ${imdbId}`);
175
+ cacheSet(ck, null);
176
+ return null;
 
 
 
 
 
 
 
 
 
 
 
177
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
+ console.log(`[IMDb] Found ${results.length} result(s)`);
 
 
180
 
181
+ // For movies (no season requested), return the first result
182
+ if (!season) {
183
+ const slug = results[0].slug;
184
+ console.log(`[IMDb] Movie slug: ${slug}`);
185
+ cacheSet(ck, slug);
186
+ return slug;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  }
188
 
189
+ // For series, find the matching season
190
+ const seasonWord = SEASON_WORDS[season - 1] || '';
191
+ if (seasonWord) {
192
+ const match = results.find(r => r.text.includes(seasonWord));
193
+ if (match) {
194
+ console.log(`[IMDb] Series S${season} slug: ${match.slug}`);
195
+ cacheSet(ck, match.slug);
196
+ return match.slug;
 
 
 
 
 
197
  }
198
  }
199
 
200
+ // Fallback: return the first result (may be the base show page)
201
+ console.log(`[IMDb] No exact season match, using first result: ${results[0].slug}`);
202
+ cacheSet(ck, results[0].slug);
203
+ return results[0].slug;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  }
205
 
206
  // ── Subtitle scraping ──────────────────────────────────────────────
 
275
  });
276
 
277
  if (!raw.length) {
 
278
  $('a[href]').each((_, a) => {
279
  const href = $(a).attr('href') || '';
280
  if (!href.includes(`/${lang}/`) || !/\/\d+$/.test(href)) return;
 
293
  return enriched;
294
  }
295
 
296
+ // ── Scoring ────────────────────────────────────────────────────────
297
  const OFFICIAL_SOURCES = ['netflix','nf','amzn','amazon','osn','shahid','wavo','hbo','hulu','disney','dsnp','appletv','atvp'];
298
 
299
+ function scoreTrack(sub) {
300
  const text = (sub.releaseInfo || '').toLowerCase();
301
  let s = 0;
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  if (OFFICIAL_SOURCES.some(src => text.includes(src))) s += 150;
304
+ if (/\b(synced|resync|corrected|fixed|proper|repack|retail|sync\.fix)\b/i.test(text)) s += 80;
 
 
 
 
 
 
 
 
 
 
 
 
305
  if (/\b(sdh|hi\.|hi_|hearing.impaired)\b/i.test(text) || sub.hearingImpaired) s -= 40;
 
 
306
  if (sub.rating) s += Math.round((sub.rating || 0) * 4);
307
+ if (/\.(ass|ssa)$/i.test(sub.releaseInfo || '')) s += 5;
 
 
308
 
309
  return s;
310
  }
 
358
  const BASE_URL = process.env.SPACE_HOST ? `https://${process.env.SPACE_HOST}` : `http://localhost:${PORT}`;
359
 
360
  app.get('/manifest.json', (req, res) => res.json({
361
+ id: 'org.van.vansubs.v3',
362
+ name: 'VanSubs+ (Subf2m IMDb)',
363
+ description: 'Human-verified Arabic & English. IMDb-direct. No AI.',
364
+ version: '3.0.0',
365
  resources: ['subtitles'],
366
  types: ['movie', 'series'],
367
  idPrefixes: ['tt'],
 
398
  const extra = req.params.extra ? decodeURIComponent(req.params.extra.replace('.json','')) : '';
399
  const streamTitle = (extra.match(/filename=([^&]+)/i) || [])[1] || '';
400
 
401
+ console.log(`\n╔═ [Subf2m v3] ${type.toUpperCase()} ${imdbId}` +
402
  (season ? ` S${String(season).padStart(2,'0')}E${String(episode??0).padStart(2,'0')}` : '') +
403
  (streamTitle ? `\nβ•‘ hint: "${streamTitle}"` : ''));
404
 
405
+ // --- IMDb-direct slug resolution ---
406
+ const slug = await resolveSlugByImdb(imdbId, season);
 
 
 
 
 
 
407
  if (!slug) return res.json({ subtitles: [] });
408
+ console.log(`β•‘ Slug: ${slug}`);
409
 
410
  // --- fetch subtitles ---
411
  const [engSubs, araSubs] = await Promise.all([
 
413
  getSubsList(slug, 'arabic')
414
  ]);
415
 
416
+ // --- episode filter ---
417
  let engFiltered = engSubs, araFiltered = araSubs;
418
  if (type === 'series' && season && episode) {
419
  const expected = `s${String(season).padStart(2,'0')}e${String(episode).padStart(2,'0')}`;
420
+ const engExact = engSubs.filter(s => (s.releaseInfo||'').toLowerCase().includes(expected));
421
+ const araExact = araSubs.filter(s => (s.releaseInfo||'').toLowerCase().includes(expected));
422
+ if (engExact.length || araExact.length) {
423
+ engFiltered = engExact;
424
+ araFiltered = araExact;
425
  }
426
  }
427
 
428
  // --- score & sort ---
429
+ const processList = (list) => {
430
+ return list
431
+ .map(s => ({ ...s, score: scoreTrack(s) }))
432
+ .sort((a, b) => b.score - a.score)
433
+ .slice(0, MAX_PER_LANG);
 
 
434
  };
435
 
436
+ const engTop = processList(engFiltered);
437
+ const araTop = processList(araFiltered);
438
 
439
  // --- resolve ZIPs in parallel ---
440
  const resolveAll = async (arr) => {
 
477
  }
478
  });
479
 
480
+ app.listen(PORT, '0.0.0.0', () => console.log(`\nβœ… Subf2m v3 (IMDb-direct) β†’ http://0.0.0.0:${PORT}`));