Update index.js
Browse files
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
|
| 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
|
| 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 >
|
| 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 |
-
//
|
| 359 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
// ================================================================
|
| 361 |
async function resolveSeriesSlug(imdbId, season) {
|
| 362 |
-
const ck = `
|
| 363 |
const hit = cacheGet(ck);
|
| 364 |
if (hit !== null) return hit;
|
| 365 |
|
| 366 |
-
const tmdb
|
| 367 |
-
const title
|
| 368 |
-
const
|
| 369 |
-
const
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 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 |
-
//
|
| 392 |
if (title) {
|
| 393 |
-
|
| 394 |
if (slug) { cacheSet(ck, slug); return slug; }
|
| 395 |
}
|
| 396 |
-
|
| 397 |
-
//
|
| 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 |
-
|
| 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,
|
| 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 |
-
//
|
| 446 |
-
|
| 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 |
-
//
|
| 468 |
-
if (ord && slugLow.includes(`-${ord}-season`)) score = Math.max(score, 80);
|
| 469 |
-
if (
|
| 470 |
-
if (slugLow.includes(`-
|
| 471 |
-
if (slugLow.includes(`-
|
| 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,
|
| 491 |
-
const base
|
| 492 |
-
const
|
| 493 |
-
const
|
| 494 |
-
const pad = String(season).padStart(2, '0');
|
| 495 |
const patterns = [];
|
| 496 |
-
|
| 497 |
-
//
|
| 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 |
-
//
|
| 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}"
|
| 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 = `
|
| 580 |
const hit = cacheGet(ck);
|
| 581 |
if (hit !== null) return hit;
|
| 582 |
|
| 583 |
-
const tmdb
|
| 584 |
-
const title
|
| 585 |
-
const
|
| 586 |
-
|
| 587 |
-
|
| 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 |
-
|
| 596 |
-
|
| 597 |
-
|
| 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
|
| 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
|
| 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
|
| 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];
|
| 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 = `
|
| 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 = `
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 842 |
name : 'VanSubF2M+',
|
| 843 |
description: 'Human-Verified Arabic & English subtitles. Anti-AI. Enhanced 100% coverage.',
|
| 844 |
-
version : '
|
| 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 = `
|
| 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 |
});
|