vanseleem commited on
Commit
7b5f22a
·
verified ·
1 Parent(s): b74805f

Update index.js

Browse files
Files changed (1) hide show
  1. index.js +157 -227
index.js CHANGED
@@ -35,6 +35,7 @@ const REQ_TIMEOUT = 18_000;
35
  const DL_TIMEOUT = 30_000;
36
  const MAX_DETAIL = 30;
37
 
 
38
  const ORDINALS = [
39
  '', 'first', 'second', 'third', 'fourth', 'fifth',
40
  'sixth', 'seventh', 'eighth', 'ninth', 'tenth',
@@ -155,6 +156,7 @@ async function getHTML(url, tries = 3) {
155
  return null;
156
  }
157
 
 
158
  async function getBinary(url, tries = 3) {
159
  for (let i = 0; i < tries; i++) {
160
  try {
@@ -162,6 +164,7 @@ async function getBinary(url, tries = 3) {
162
  if (r !== null) return r;
163
  } catch (_) {}
164
  }
 
165
  const enc = encodeURIComponent(url);
166
  const proxied = [
167
  `${PROXY_1}${enc}`,
@@ -199,25 +202,16 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
199
  // ================================================================
200
  // STRING UTILS
201
  // ================================================================
202
-
203
- // Standard slug: lowercases, strips accents, keeps only a-z0-9 and spaces→dashes
204
- // IMPORTANT: does NOT collapse multiple dashes — subf2m uses double-dash for some titles (e.g. friends--first-season)
205
  function titleToSlug(title, year) {
206
  const base = (title || '').toLowerCase()
207
  .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
208
- .replace(/[^a-z0-9\s-]/g, '') // keep existing dashes too
209
  .replace(/\s+/g, '-')
 
210
  .replace(/^-|-$/g, '');
211
  return year ? `${base}-${year}` : base;
212
  }
213
-
214
- // Slug builder that PRESERVES double-dashes coming from search result hrefs
215
- // (subf2m itself outputs these — we must not sanitise them away)
216
- function hrefToSlug(href) {
217
- const parts = href.split('/').filter(Boolean);
218
- return parts.length >= 2 ? parts[1] : '';
219
- }
220
-
221
  function _clean(t) { return (t || '').replace(/\s+/g, ' ').trim(); }
222
 
223
  // ================================================================
@@ -271,7 +265,7 @@ function scoreSubtitle(sub, streamTokens) {
271
  }
272
 
273
  // ================================================================
274
- // TMDB — now also returns originalName + originalLanguage
275
  // ================================================================
276
  async function getTMDB(imdbId) {
277
  const ck = `tmdb:${imdbId}`;
@@ -287,24 +281,10 @@ async function getTMDB(imdbId) {
287
  let info = null;
288
  if (d.movie_results?.length) {
289
  const m = d.movie_results[0];
290
- info = {
291
- title : m.title || m.original_title,
292
- originalTitle : m.original_title || m.title,
293
- originalLanguage: m.original_language || 'en',
294
- year : m.release_date?.substring(0,4) || null,
295
- type : 'movie',
296
- tmdbId : m.id,
297
- };
298
  } else if (d.tv_results?.length) {
299
  const t = d.tv_results[0];
300
- info = {
301
- title : t.name || t.original_name,
302
- originalTitle : t.original_name || t.name,
303
- originalLanguage: t.original_language || 'en',
304
- year : t.first_air_date?.substring(0,4) || null,
305
- type : 'series',
306
- tmdbId : t.id,
307
- };
308
  }
309
  cacheSet(ck, info);
310
  return info;
@@ -326,20 +306,24 @@ async function getTMDBSeasonYear(tmdbId, season) {
326
  }
327
 
328
  // ================================================================
329
- // SLUG PROBING
 
330
  // ================================================================
331
  async function _probeSlug(slug, opts = {}) {
332
- if (!slug || slug.length > 280) return false;
333
  try {
334
  const lang = opts.lang || 'english';
335
  const url = `${BASE}/subtitles/${slug}/${lang}`;
336
  const html = await getHTML(url, 1);
337
  if (!html || html.length < 500) return false;
 
338
  if (!html.includes('<li') && !html.includes('subtitle') && !html.includes('download')) return false;
 
339
  if (opts.season) {
340
  const s = Number(opts.season);
341
  const pad = String(s).padStart(2, '0');
342
  const ord = (ORDINALS[s] || '').toLowerCase();
 
343
  const hasSeasonRef = (
344
  html.includes(`S${pad}E`) || html.includes(`s${pad}e`) ||
345
  html.includes(`season ${s}`) || html.includes(`Season ${s}`) ||
@@ -354,125 +338,111 @@ async function _probeSlug(slug, opts = {}) {
354
  }
355
 
356
  // ================================================================
357
- // SERIES SLUG RESOLUTION
358
- // New: handles anime (original_language != en → search by original title too)
359
- // handles double-dash slugs (friends--first-season)
 
 
 
 
360
  // ================================================================
361
  async function resolveSeriesSlug(imdbId, season) {
362
- const ck = `sslug3:${imdbId}:${season}`;
363
  const hit = cacheGet(ck);
364
  if (hit !== null) return hit;
365
 
366
- const tmdb = await getTMDB(imdbId);
367
- const title = tmdb?.title;
368
- const origTitle = tmdb?.originalTitle;
369
- const origLang = tmdb?.originalLanguage || 'en';
370
- const showYear = tmdb?.year;
371
- const seasonYear = tmdb?.tmdbId ? await getTMDBSeasonYear(tmdb.tmdbId, season) : null;
372
- const isAnime = origLang !== 'en' && origTitle && origTitle !== title;
373
-
374
- // Build ordered list of queries to try
375
- const queries = [];
376
-
377
- // 1. IMDb ID — most reliable, always first
378
- queries.push(imdbId);
379
-
380
- // 2. Original (non-English) title — critical for anime like Attack on Titan → shingeki-no-kyojin
381
- if (isAnime) queries.push(origTitle);
382
-
383
- // 3. English title
384
- if (title) queries.push(title);
385
-
386
- for (const q of queries) {
387
- const slug = await _searchSeriesSlugByQuery(q, season, title, origTitle, seasonYear, showYear, isAnime);
388
  if (slug) { cacheSet(ck, slug); return slug; }
389
  }
390
-
391
- // 4. Smart pattern brute-force using both title forms
392
  if (title) {
393
- let slug = await _smartPatternSlugs(title, origTitle, season, seasonYear, showYear, isAnime);
394
  if (slug) { cacheSet(ck, slug); return slug; }
395
  }
396
-
397
- // 5. Bare slug fallback
398
  if (title) {
399
  const bare = titleToSlug(title, null);
400
  if (await _probeSlug(bare, { season })) { cacheSet(ck, bare); return bare; }
 
401
  if (showYear) {
402
  const withYear = titleToSlug(title, showYear);
403
  if (await _probeSlug(withYear, { season })) { cacheSet(ck, withYear); return withYear; }
404
  }
405
  }
406
- if (isAnime && origTitle) {
407
- const bareOrig = titleToSlug(origTitle, null);
408
- if (await _probeSlug(bareOrig, { season })) { cacheSet(ck, bareOrig); return bareOrig; }
409
- }
410
-
411
  cacheSet(ck, null);
412
  return null;
413
  }
414
 
415
- async function _searchSeriesSlugByQuery(query, season, titleHint, origTitleHint, seasonYear, showYear, isAnime) {
416
  const searchUrl = `${BASE}/subtitles/searchbytitle?query=${encodeURIComponent(query)}&l=`;
417
  const html = await getHTML(searchUrl);
418
  if (!html) return null;
419
  const $ = cheerio.load(html);
420
  const ord = (ORDINALS[season] || '').toLowerCase();
421
-
422
- // First word of the English title slug (for filtering unrelated results)
423
- const engFirstWord = titleHint ? titleToSlug(titleHint, null).split('-')[0] : '';
424
- // First word of the original title slug (for anime)
425
- const origFirstWord = origTitleHint ? titleToSlug(origTitleHint, null).split('-')[0] : '';
426
-
427
  const results = [];
428
  const seen = new Set();
429
 
 
 
 
 
430
  const SERIES_RE = /^(.+?)\s*[-–]\s*(.*?)\s*(season|series)\s*(\((\d{4})\))?\s*$/i;
 
431
 
432
  $('a[href]').each((_, a) => {
433
  const href = $(a).attr('href') || '';
434
  if (!href.startsWith('/subtitles/')) return;
435
  const parts = href.split('/').filter(Boolean);
436
  if (parts.length < 2) return;
437
- // Use raw href segment — PRESERVES double-dash (friends--first-season)
438
  const slug = parts[1];
439
  if (!slug || slug === 'searchbytitle' || seen.has(slug)) return;
440
  seen.add(slug);
441
 
442
  const rawText = _clean($(a).text());
443
  const slugLow = slug.toLowerCase();
444
-
445
- // Filter: slug must start with a known first-word (eng or orig)
446
- const matchesEng = engFirstWord && slugLow.startsWith(engFirstWord);
447
- const matchesOrig = origFirstWord && slugLow.startsWith(origFirstWord);
448
- if (engFirstWord && origFirstWord) {
449
- if (!matchesEng && !matchesOrig) return;
450
- } else if (engFirstWord && !matchesEng) return;
451
- else if (origFirstWord && !matchesOrig) return;
452
 
453
  let score = 0;
454
-
 
455
  const seriesMatch = SERIES_RE.exec(rawText);
456
  if (seriesMatch) {
457
  const subOrd = (seriesMatch[2] || '').trim().toLowerCase();
458
  const subYear = seriesMatch[5];
 
459
  if (ord && subOrd === ord) score += 200;
460
  else if (ord && subOrd.startsWith(ord)) score += 150;
 
461
  if (subOrd === String(season)) score += 100;
 
462
  if (seasonYear && subYear === seasonYear) score += 50;
463
  else if (showYear && subYear === showYear && season === 1) score += 30;
 
464
  if (score === 0) score += 10;
465
  }
466
 
467
- // Slug pattern scoring (works with double-dash too)
468
- if (ord && slugLow.includes(`-${ord}-season`)) score = Math.max(score, 80);
469
- if (ord && slugLow.includes(`--${ord}-season`)) score = Math.max(score, 85); // double-dash bonus
470
- if (slugLow.includes(`-season-${season}-`)) score = Math.max(score, 70);
471
- if (slugLow.includes(`-s${String(season).padStart(2,'0')}`)) score = Math.max(score, 60);
472
- if (ord && slugLow.includes(`-${ord}season`)) score = Math.max(score, 50);
473
-
474
- // Anime-specific: bonus when slug contains the original-language romanization
475
- if (isAnime && origFirstWord && slugLow.startsWith(origFirstWord)) score += 30;
476
 
477
  if (score > 0) results.push({ slug, score });
478
  });
@@ -487,80 +457,50 @@ async function _searchSeriesSlugByQuery(query, season, titleHint, origTitleHint,
487
  return null;
488
  }
489
 
490
- async function _smartPatternSlugs(title, origTitle, season, seasonYear, showYear, isAnime) {
491
- const base = titleToSlug(title, null);
492
- const origBase = origTitle ? titleToSlug(origTitle, null) : null;
493
- const ord = ORDINALS[season] || '';
494
- const pad = String(season).padStart(2, '0');
495
  const patterns = [];
496
-
497
- // ── Anime-first patterns (original title base) ──────────────────
498
- if (isAnime && origBase) {
499
- if (seasonYear) {
500
- if (ord) {
501
- patterns.push(`${origBase}-${ord}-season-${seasonYear}`);
502
- patterns.push(`${origBase}-${ord}-season`);
503
- }
504
- patterns.push(`${origBase}-${seasonYear}`);
505
- patterns.push(`${origBase}-season-${season}-${seasonYear}`);
506
- }
507
- if (ord) {
508
- patterns.push(`${origBase}-${ord}-season`);
509
- patterns.push(`${origBase}-${ord}-series`);
510
- }
511
- patterns.push(`${origBase}-season-${season}`);
512
- patterns.push(`${origBase}`);
513
- // Compound slug e.g. shingeki-no-kyojin-attack-on-titan (S1 of AoT)
514
- patterns.push(`${origBase}-${base}`);
515
- if (ord) {
516
- patterns.push(`${origBase}-${ord}-season-${base}`);
517
- patterns.push(`${origBase}-${ord}-season-${base}-${season}`);
518
- // e.g. shingeki-no-kyojin-second-season-attack-on-titan-2
519
- patterns.push(`${origBase}-${ord}-season-${base}-${season}`);
520
- }
521
- // e.g. shingeki-no-kyojin-attack-on-titan-fourth-season
522
- if (ord) patterns.push(`${origBase}-${base}-${ord}-season`);
523
- if (ord && seasonYear) patterns.push(`${origBase}-${base}-${ord}-season-${seasonYear}`);
524
- }
525
-
526
- // ── Standard English title patterns ─────────────────────────────
527
  if (seasonYear) {
528
  if (ord) {
529
  patterns.push(`${base}-${ord}-season-${seasonYear}`);
530
- // Double-dash variant: friends--first-season-1994
531
  patterns.push(`${base}--${ord}-season-${seasonYear}`);
 
532
  patterns.push(`${base}-the-${ord}-season-${seasonYear}`);
533
  }
534
  patterns.push(`${base}-season-${season}-${seasonYear}`);
535
  patterns.push(`${base}-s${pad}-${seasonYear}`);
536
  patterns.push(`${base}-${seasonYear}`);
537
  }
538
-
 
539
  if (ord) {
540
  patterns.push(`${base}-${ord}-season`);
541
- // Double-dash variant: friends--first-season
542
  patterns.push(`${base}--${ord}-season`);
543
  patterns.push(`${base}-the-${ord}-season`);
544
  patterns.push(`${base}-${ord}-series`);
545
  }
546
  patterns.push(`${base}-season-${season}`);
547
  patterns.push(`${base}-s${pad}`);
548
-
549
- // Adjacent years
550
  const yearBase = seasonYear ? +seasonYear : (showYear ? +showYear : null);
551
  if (yearBase) {
552
  for (const yr of [yearBase - 1, yearBase + 1, yearBase - 2]) {
553
  if (ord) {
554
  patterns.push(`${base}-${ord}-season-${yr}`);
555
- patterns.push(`${base}--${ord}-season-${yr}`);
556
  }
557
  patterns.push(`${base}-season-${season}-${yr}`);
558
  patterns.push(`${base}-${yr}`);
559
  }
560
  }
561
-
562
  const unique = [...new Set(patterns)].filter(Boolean);
563
- console.log(`[slug] Probing ${unique.length} patterns for "${title}"${isAnime ? ` / "${origTitle}"` : ''} S${season}`);
564
 
565
  for (const s of unique) {
566
  await sleep(120);
@@ -576,57 +516,36 @@ async function _smartPatternSlugs(title, origTitle, season, seasonYear, showYear
576
  // MOVIE SLUG RESOLUTION
577
  // ================================================================
578
  async function resolveMovieSlug(imdbId, titleHint, yearHint) {
579
- const ck = `mslug3:${imdbId}`;
580
  const hit = cacheGet(ck);
581
  if (hit !== null) return hit;
582
 
583
- const tmdb = await getTMDB(imdbId);
584
- const title = tmdb?.title || titleHint;
585
- const origTitle = tmdb?.originalTitle;
586
- const origLang = tmdb?.originalLanguage || 'en';
587
- const year = tmdb?.year || yearHint;
588
- const isAnime = origLang !== 'en' && origTitle && origTitle !== title;
589
-
590
- // Direct slug attempt (English)
591
  if (title && year) {
592
  const direct = titleToSlug(title, year);
593
  if (await _probeMovieSlug(direct, title)) { cacheSet(ck, direct); return direct; }
594
  }
595
- // Direct slug attempt (original title for anime)
596
- if (isAnime && origTitle && year) {
597
- const directOrig = titleToSlug(origTitle, year);
598
- if (await _probeMovieSlug(directOrig, title)) { cacheSet(ck, directOrig); return directOrig; }
599
- }
600
-
601
- // Search by IMDb ID
602
- let slug = await _searchMovieSlug(imdbId, year, title, origTitle);
603
  if (slug && await _probeMovieSlug(slug, title)) { cacheSet(ck, slug); return slug; }
604
-
605
- // Search by original title (anime)
606
- if (isAnime && origTitle) {
607
- slug = await _searchMovieSlug(origTitle, year, title, origTitle);
608
- if (slug && await _probeMovieSlug(slug, title)) { cacheSet(ck, slug); return slug; }
609
- }
610
-
611
- // Search by English title
612
  if (title) {
613
- slug = await _searchMovieSlug(title, year, title, origTitle);
614
  if (slug && await _probeMovieSlug(slug, title)) { cacheSet(ck, slug); return slug; }
615
-
 
616
  const candidates = [];
617
  if (year) {
618
  candidates.push(titleToSlug(title, year));
619
  candidates.push(titleToSlug(title, String(+year - 1)));
620
  candidates.push(titleToSlug(title, String(+year + 1)));
621
  }
622
- if (isAnime && origTitle) {
623
- if (year) {
624
- candidates.push(titleToSlug(origTitle, year));
625
- candidates.push(titleToSlug(origTitle, String(+year - 1)));
626
- candidates.push(titleToSlug(origTitle, String(+year + 1)));
627
- }
628
- candidates.push(titleToSlug(origTitle, null));
629
- }
630
  candidates.push(titleToSlug(title, null));
631
  for (const c of [...new Set(candidates)]) {
632
  await sleep(250);
@@ -638,7 +557,7 @@ async function resolveMovieSlug(imdbId, titleHint, yearHint) {
638
  return null;
639
  }
640
 
641
- async function _searchMovieSlug(query, yearHint, titleHint, origTitleHint) {
642
  const url = `${BASE}/subtitles/searchbytitle?query=${encodeURIComponent(query)}&l=`;
643
  const html = await getHTML(url);
644
  if (!html) return null;
@@ -656,7 +575,7 @@ async function _searchMovieSlug(query, yearHint, titleHint, origTitleHint) {
656
  if (!href.startsWith('/subtitles/') || href === '/subtitles/searchbytitle') return;
657
  const parts = href.split('/').filter(Boolean);
658
  if (parts.length < 2) return;
659
- const slug = parts[1]; // raw — preserves double-dash
660
  if (!slug || slug === 'searchbytitle') return;
661
  const text = _clean(a.text());
662
  const yrMatch = text.match(/\((\d{4})\)/);
@@ -664,6 +583,7 @@ async function _searchMovieSlug(query, yearHint, titleHint, origTitleHint) {
664
  let score = 1;
665
  if (query.startsWith('tt')) score += 100;
666
  if (yearHint && resYear === +yearHint) score += 10;
 
667
  if (text.toLowerCase().includes('season')) score -= 5;
668
  results.push({ slug, score });
669
  found = true;
@@ -685,6 +605,7 @@ async function _probeMovieSlug(slug, expectedTitle) {
685
  const $ = cheerio.load(html);
686
  const pageTitle = ($('title').text() || '').toLowerCase();
687
  const exp = expectedTitle.toLowerCase();
 
688
  return pageTitle.includes(exp) || html.includes('class="item"') || html.includes('class="r"');
689
  }
690
  return true;
@@ -696,7 +617,7 @@ async function _probeMovieSlug(slug, expectedTitle) {
696
  // ================================================================
697
  async function getSubsList(slug, lang, season, episode, streamTokens) {
698
  if (!ALLOWED_LANGS.has(lang)) return [];
699
- const ck = `list3:${slug}:${lang}:${episode || 0}`;
700
  const hit = cacheGet(ck);
701
  if (hit) return hit;
702
 
@@ -706,12 +627,15 @@ async function getSubsList(slug, lang, season, episode, streamTokens) {
706
  const $ = cheerio.load(html);
707
  let raw = [];
708
 
 
709
  $('li.item').each((_, el) => { _extractItem($, el, lang, raw); });
710
 
 
711
  if (!raw.length) {
712
  $('tr, .subtitle-item, .item').each((_, el) => { _extractItem($, el, lang, raw); });
713
  }
714
 
 
715
  if (!raw.length) {
716
  $('a[href]').each((_, a) => {
717
  const href = $(a).attr('href') || '';
@@ -724,6 +648,7 @@ async function getSubsList(slug, lang, season, episode, streamTokens) {
724
  });
725
  }
726
 
 
727
  if (season && episode && raw.length) {
728
  const expected = `s${String(season).padStart(2,'0')}e${String(episode).padStart(2,'0')}`;
729
  const filtered = raw.filter(item => {
@@ -733,6 +658,7 @@ async function getSubsList(slug, lang, season, episode, streamTokens) {
733
  if (filtered.length) raw = filtered;
734
  }
735
 
 
736
  const seen = new Set();
737
  const unique = raw.filter(s => { if (seen.has(s.subId)) return false; seen.add(s.subId); return true; });
738
 
@@ -786,7 +712,7 @@ function _extractItem($, el, lang, out) {
786
  }
787
 
788
  async function _enrichSub(sub) {
789
- const ck = `det3:${sub.subId}`;
790
  const hit = cacheGet(ck);
791
  if (hit !== null) { if (hit.__aiBlocked) return null; return { ...sub, ...hit }; }
792
 
@@ -816,7 +742,56 @@ async function _enrichSub(sub) {
816
  }
817
 
818
  // ================================================================
819
- // SORT
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
  // ================================================================
821
  function sortForBest(subs, streamTokens, type) {
822
  if (type === 'series' && streamTokens) {
@@ -828,6 +803,7 @@ function sortForBest(subs, streamTokens, type) {
828
  return b.downloads - a.downloads;
829
  });
830
  }
 
831
  return subs.slice().sort((a, b) => {
832
  if (b.isGood !== a.isGood) return b.isGood ? 1 : -1;
833
  return b.downloads - a.downloads;
@@ -838,10 +814,10 @@ function sortForBest(subs, streamTokens, type) {
838
  // ROUTES
839
  // ================================================================
840
  app.get('/manifest.json', (req, res) => res.json({
841
- id : 'org.van.vansubf2mplus.v3',
842
  name : 'VanSubF2M+',
843
  description: 'Human-Verified Arabic & English subtitles. Anti-AI. Enhanced 100% coverage.',
844
- version : '3.0.0',
845
  resources : ['subtitles'],
846
  types : ['movie', 'series'],
847
  idPrefixes : ['tt'],
@@ -867,7 +843,7 @@ app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
867
  const { type, id } = req.params;
868
  if (!id?.startsWith('tt')) return res.json({ subtitles: [] });
869
 
870
- const ck = `result3:${id}`;
871
  const hit = cacheGet(ck);
872
  if (hit) return res.json({ subtitles: hit });
873
 
@@ -902,6 +878,7 @@ app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
902
  let engSubs = (engRes.status === 'fulfilled' ? engRes.value : []);
903
  let araSubs = (araRes.status === 'fulfilled' ? araRes.value : []);
904
 
 
905
  engSubs = sortForBest(engSubs, streamTokens, type).slice(0, MAX_PER_LANG);
906
  araSubs = sortForBest(araSubs, streamTokens, type).slice(0, MAX_PER_LANG);
907
 
@@ -928,53 +905,6 @@ app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
928
  }
929
  });
930
 
931
- // ================================================================
932
- // DOWNLOAD & SRT EXTRACTION
933
- // ================================================================
934
- async function fetchSRT(downloadUrl) {
935
- const ck = `srt3:${downloadUrl}`;
936
- const hit = cacheGet(ck);
937
- if (hit !== null) return hit;
938
- try {
939
- const buf = await getBinary(downloadUrl);
940
- if (!buf) return null;
941
- const content = _extractFromZip(buf) ?? _tryDirect(buf);
942
- if (!content) return null;
943
- cacheSet(ck, content);
944
- return content;
945
- } catch (_) { return null; }
946
- }
947
-
948
- function _extractFromZip(buf) {
949
- try {
950
- const zip = new AdmZip(buf);
951
- const RANK = { '.srt': 0, '.vtt': 1, '.ass': 2, '.ssa': 3 };
952
- const files = zip.getEntries()
953
- .filter(e => {
954
- const n = e.entryName.toLowerCase();
955
- return !e.isDirectory && !n.startsWith('__macosx') && Object.keys(RANK).some(x => n.endsWith(x));
956
- })
957
- .sort((a, b) => (RANK[_ext(a.entryName)] ?? 9) - (RANK[_ext(b.entryName)] ?? 9));
958
- for (const f of files) {
959
- const raw = zip.readFile(f);
960
- if (!raw) continue;
961
- const text = _decode(raw);
962
- if (_isSub(text)) return text;
963
- }
964
- } catch (_) {}
965
- return null;
966
- }
967
-
968
- function _tryDirect(buf) { const t = _decode(buf); return _isSub(t) ? t : null; }
969
- const _ext = n => (n.match(/(\.[^.]+)$/)?.[1]?.toLowerCase() ?? '');
970
- const _isSub = t => typeof t === 'string' && t.includes('-->') && t.length > 50;
971
- function _decode(buf) {
972
- const hasBOM = buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF;
973
- const u8 = buf.slice(hasBOM ? 3 : 0).toString('utf8');
974
- if (_isSub(u8)) return u8;
975
- return buf.toString('latin1');
976
- }
977
-
978
  app.listen(PORT, '0.0.0.0', () => {
979
  console.log(`Server running on port ${PORT}`);
980
  });
 
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',
 
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 {
 
164
  if (r !== null) return r;
165
  } catch (_) {}
166
  }
167
+ // Proxy chain
168
  const enc = encodeURIComponent(url);
169
  const proxied = [
170
  `${PROXY_1}${enc}`,
 
202
  // ================================================================
203
  // STRING UTILS
204
  // ================================================================
 
 
 
205
  function titleToSlug(title, year) {
206
  const base = (title || '').toLowerCase()
207
  .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
208
+ .replace(/[^a-z0-9\s]/g, '')
209
  .replace(/\s+/g, '-')
210
+ .replace(/-+/g, '-')
211
  .replace(/^-|-$/g, '');
212
  return year ? `${base}-${year}` : base;
213
  }
214
+
 
 
 
 
 
 
 
215
  function _clean(t) { return (t || '').replace(/\s+/g, ' ').trim(); }
216
 
217
  // ================================================================
 
265
  }
266
 
267
  // ================================================================
268
+ // TMDB
269
  // ================================================================
270
  async function getTMDB(imdbId) {
271
  const ck = `tmdb:${imdbId}`;
 
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;
 
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}`) ||
 
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
  });
 
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);
 
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);
 
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;
 
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})\)/);
 
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;
 
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;
 
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);
622
  if (hit) return hit;
623
 
 
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') || '';
 
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 => {
 
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
 
 
712
  }
713
 
714
  async function _enrichSub(sub) {
715
+ const ck = `det2:${sub.subId}`;
716
  const hit = cacheGet(ck);
717
  if (hit !== null) { if (hit.__aiBlocked) return null; return { ...sub, ...hit }; }
718
 
 
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) {
762
+ try {
763
+ const zip = new AdmZip(buf);
764
+ const RANK = { '.srt': 0, '.vtt': 1, '.ass': 2, '.ssa': 3 };
765
+ const files = zip.getEntries()
766
+ .filter(e => {
767
+ const n = e.entryName.toLowerCase();
768
+ return !e.isDirectory && !n.startsWith('__macosx') && Object.keys(RANK).some(x => n.endsWith(x));
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;
776
+ }
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;
784
+ function _decode(buf) {
785
+ const hasBOM = buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF;
786
+ const u8 = buf.slice(hasBOM ? 3 : 0).toString('utf8');
787
+ if (_isSub(u8)) return u8;
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) {
 
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;
 
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'],
 
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 });
849
 
 
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
 
 
905
  }
906
  });
907
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
908
  app.listen(PORT, '0.0.0.0', () => {
909
  console.log(`Server running on port ${PORT}`);
910
  });