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