Update index.js
Browse files
index.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
| 1 |
-
'use strict';
|
| 2 |
-
|
| 3 |
const express = require('express');
|
| 4 |
const cheerio = require('cheerio');
|
| 5 |
const AdmZip = require('adm-zip');
|
|
@@ -12,98 +10,140 @@ const app = express();
|
|
| 12 |
const PORT = process.env.PORT || 7860;
|
| 13 |
|
| 14 |
app.use((req, res, next) => {
|
| 15 |
-
res.header('Access-Control-Allow-Origin',
|
| 16 |
res.header('Access-Control-Allow-Headers', '*');
|
| 17 |
res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
| 18 |
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
| 19 |
next();
|
| 20 |
});
|
| 21 |
|
| 22 |
-
/
|
| 23 |
-
CONFIG
|
| 24 |
-
=================================================================== */
|
| 25 |
const BASE = 'https://subf2m.co';
|
| 26 |
-
const TMDB_KEY = '30166e2db6b420d3f230808cd1bb2c90';
|
| 27 |
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
| 28 |
const MAX_PER_LANG = 4;
|
| 29 |
const CACHE_TTL = 6 * 60 * 60 * 1000;
|
| 30 |
const REQ_TIMEOUT = 15_000;
|
| 31 |
const DL_TIMEOUT = 25_000;
|
| 32 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
|
|
|
| 34 |
const ALLOWED_LANGS = new Set(['english', 'arabic']);
|
| 35 |
|
| 36 |
-
/
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
/computer[\s\-_](translat|generat)/i,
|
| 47 |
];
|
| 48 |
-
const isAISub = (text) => AI_PATTERNS.some(rx => rx.test(text || ''));
|
| 49 |
-
|
| 50 |
-
/* ===================================================================
|
| 51 |
-
GLOBALS & CACHE
|
| 52 |
-
=================================================================== */
|
| 53 |
-
const cache = new Map();
|
| 54 |
-
const subStore = new Map();
|
| 55 |
-
let subSeq = 0;
|
| 56 |
-
|
| 57 |
-
setInterval(() => {
|
| 58 |
-
const now = Date.now(); let n = 0;
|
| 59 |
-
for (const [k, v] of cache.entries()) { if (now - v.ts > v.ttl) { cache.delete(k); n++; } }
|
| 60 |
-
if (n > 0) console.log(`[Cache] Evicted ${n} stale entries`);
|
| 61 |
-
}, 15 * 60 * 1000);
|
| 62 |
-
|
| 63 |
-
setInterval(() => {
|
| 64 |
-
if (subStore.size > 500) {
|
| 65 |
-
const keys = [...subStore.keys()].slice(0, 250);
|
| 66 |
-
keys.forEach(k => subStore.delete(k));
|
| 67 |
-
console.log('[ZipStore] Flushed 250 old entries');
|
| 68 |
-
}
|
| 69 |
-
}, 30 * 60 * 1000);
|
| 70 |
-
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
| 71 |
|
| 72 |
-
/
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
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';
|
| 76 |
|
| 77 |
-
function
|
| 78 |
return new Promise((resolve, reject) => {
|
| 79 |
-
const parsed
|
| 80 |
-
const lib
|
| 81 |
-
const
|
| 82 |
-
const reqOpts = {
|
| 83 |
hostname: parsed.hostname,
|
| 84 |
port : parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
| 85 |
path : parsed.pathname + parsed.search,
|
| 86 |
method : 'GET',
|
| 87 |
headers : {
|
| 88 |
-
'User-Agent'
|
| 89 |
-
'Accept'
|
| 90 |
-
'Accept-Language'
|
| 91 |
-
'Accept-Encoding'
|
| 92 |
-
'Referer'
|
| 93 |
-
'Connection'
|
| 94 |
-
'Cache-Control' : 'no-cache',
|
| 95 |
...(opts.headers || {})
|
| 96 |
}
|
| 97 |
};
|
| 98 |
-
const req = lib.request(
|
| 99 |
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
| 100 |
-
|
| 101 |
-
return _request(next, opts).then(resolve).catch(reject);
|
| 102 |
}
|
| 103 |
if (res.statusCode === 404) return resolve(null);
|
| 104 |
if (res.statusCode !== 200) return resolve(null);
|
| 105 |
const chunks = [];
|
| 106 |
-
let stream
|
| 107 |
const enc = res.headers['content-encoding'] || '';
|
| 108 |
if (enc.includes('gzip')) stream = res.pipe(zlib.createGunzip());
|
| 109 |
else if (enc.includes('br')) stream = res.pipe(zlib.createBrotliDecompress());
|
|
@@ -112,249 +152,198 @@ function _request(urlStr, opts = {}) {
|
|
| 112 |
stream.on('end', () => resolve(opts.binary ? Buffer.concat(chunks) : Buffer.concat(chunks).toString('utf8')));
|
| 113 |
stream.on('error', reject);
|
| 114 |
});
|
| 115 |
-
req.setTimeout(timeout, () => { req.destroy(); reject(new Error('
|
| 116 |
req.on('error', reject);
|
| 117 |
req.end();
|
| 118 |
});
|
| 119 |
}
|
| 120 |
-
|
| 121 |
async function getHTML(url, tries = 3) {
|
| 122 |
for (let i = 0; i < tries; i++) {
|
| 123 |
-
try { const r = await
|
| 124 |
-
if (i < tries - 1) await sleep(1200 * (i + 1));
|
| 125 |
}
|
| 126 |
return null;
|
| 127 |
}
|
| 128 |
-
|
| 129 |
-
async function getBinary(url, tries = 3) {
|
| 130 |
for (let i = 0; i < tries; i++) {
|
| 131 |
-
try { const r = await
|
| 132 |
-
if (i < tries - 1) await sleep(1200 * (i + 1));
|
| 133 |
}
|
| 134 |
return null;
|
| 135 |
}
|
| 136 |
|
| 137 |
-
/
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
const item = cache.get(key);
|
| 142 |
if (!item) return null;
|
| 143 |
-
if (Date.now() - item.ts > CACHE_TTL) {
|
| 144 |
return item.val;
|
| 145 |
}
|
| 146 |
-
function cacheSet(
|
| 147 |
-
if (
|
| 148 |
-
|
| 149 |
}
|
|
|
|
|
|
|
| 150 |
|
| 151 |
-
/
|
| 152 |
-
TMDB (for title / year metadata needed for candidate scoring)
|
| 153 |
-
=================================================================== */
|
| 154 |
async function getTMDB(imdbId) {
|
| 155 |
-
const ck = `tmdb:${imdbId}`;
|
| 156 |
-
const hit = cacheGet(ck);
|
| 157 |
if (hit !== null) return hit;
|
| 158 |
try {
|
| 159 |
-
const raw = await
|
| 160 |
if (!raw) { cacheSet(ck, null); return null; }
|
| 161 |
const d = JSON.parse(raw);
|
| 162 |
let info = null;
|
| 163 |
if (d.movie_results?.length) {
|
| 164 |
const m = d.movie_results[0];
|
| 165 |
-
info = { title: m.title || m.original_title, year: m.release_date?.
|
| 166 |
} else if (d.tv_results?.length) {
|
| 167 |
const t = d.tv_results[0];
|
| 168 |
-
info = { title: t.name || t.original_name, year: t.first_air_date?.
|
| 169 |
}
|
| 170 |
-
cacheSet(ck, info);
|
| 171 |
-
return info;
|
| 172 |
} catch { cacheSet(ck, null); return null; }
|
| 173 |
}
|
| 174 |
|
| 175 |
-
/
|
| 176 |
-
|
| 177 |
-
==
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
const
|
| 182 |
-
const yearMatch = text.match(/\((\d{4})\)/);
|
| 183 |
-
const year = yearMatch ? yearMatch[1] : null;
|
| 184 |
-
|
| 185 |
-
let score = 0;
|
| 186 |
-
|
| 187 |
-
// Title similarity (simple token overlap)
|
| 188 |
-
const expectedClean = (expectedTitle || '').toLowerCase().trim();
|
| 189 |
-
const tokensA = new Set(clean.split(' ').filter(Boolean));
|
| 190 |
-
const tokensB = new Set(expectedClean.split(' ').filter(Boolean));
|
| 191 |
-
const common = [...tokensA].filter(t => tokensB.has(t)).length;
|
| 192 |
-
const similarity = common / Math.max(tokensA.size, tokensB.size, 1);
|
| 193 |
-
score += similarity * 100;
|
| 194 |
-
|
| 195 |
-
// Year match (strong bonus)
|
| 196 |
-
if (year && expectedYear && year === String(expectedYear)) score += 100;
|
| 197 |
-
else if (year && expectedYear && Math.abs(+year - +expectedYear) <= 1) score += 40; // adjacent year
|
| 198 |
-
|
| 199 |
-
// Prefer non-collection / non-complete-series titles (less junk)
|
| 200 |
-
if (clean.includes('complete series') || clean.includes('collection')) score -= 30;
|
| 201 |
-
|
| 202 |
-
return score;
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
async function resolveBestSlug(imdbId, season, tmdbTitle, tmdbYear) {
|
| 206 |
-
const ck = `best:${imdbId}:${season ?? 'movie'}`;
|
| 207 |
const hit = cacheGet(ck);
|
| 208 |
if (hit !== null) return hit;
|
| 209 |
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
const html = await getHTML(url);
|
| 213 |
-
if (!html)
|
| 214 |
|
| 215 |
const $ = cheerio.load(html);
|
| 216 |
-
const
|
|
|
|
| 217 |
|
| 218 |
-
$('
|
| 219 |
-
const href = $(
|
| 220 |
-
if (!href.startsWith('/subtitles/')) return;
|
| 221 |
const parts = href.split('/').filter(Boolean);
|
| 222 |
if (parts.length < 2) return;
|
| 223 |
const slug = parts[1];
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
|
|
|
|
|
|
| 227 |
});
|
| 228 |
|
| 229 |
-
if (!
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
const
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
if (
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
}
|
| 259 |
-
console.log(`[IMDb] No season match, using best scored slug: ${selectedSlug}`);
|
| 260 |
-
}
|
| 261 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
}
|
| 263 |
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
}
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
const t = text.toLowerCase();
|
| 274 |
-
// SxxExx
|
| 275 |
-
let m = t.match(/s(\d{1,2})[\.\-_\s]?e(\d{1,3})/);
|
| 276 |
-
if (m) return { season: parseInt(m[1],10), episode: parseInt(m[2],10) };
|
| 277 |
-
// XxYY (e.g. 1x05)
|
| 278 |
-
m = t.match(/\b(\d{1,2})x(\d{2,3})\b/);
|
| 279 |
-
if (m) return { season: parseInt(m[1],10), episode: parseInt(m[2],10) };
|
| 280 |
-
// "Season X Episode Y"
|
| 281 |
-
m = t.match(/season\s*(\d{1,2})\s*episode\s*(\d{1,3})/i);
|
| 282 |
-
if (m) return { season: parseInt(m[1],10), episode: parseInt(m[2],10) };
|
| 283 |
-
return { season: null, episode: null };
|
| 284 |
}
|
| 285 |
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
const relLines = $('ul.release li, .release-names li, ul.scrolllist li')
|
| 313 |
-
.map((_, e) => $(e).text().trim()).get();
|
| 314 |
-
const releaseInfo = relLines.length ? relLines.join(' | ').substring(0, 130) : sub.releaseInfo;
|
| 315 |
-
|
| 316 |
-
return { ...sub, releaseInfo, goodVotes, badVotes, downloads, isGood, authorNote };
|
| 317 |
}
|
| 318 |
|
|
|
|
| 319 |
async function getSubsList(slug, lang, season, episode) {
|
| 320 |
if (!ALLOWED_LANGS.has(lang)) return [];
|
| 321 |
-
|
| 322 |
-
const ck = `list:${slug}:${lang}:${episode || 0}`;
|
| 323 |
const hit = cacheGet(ck);
|
| 324 |
if (hit) return hit;
|
| 325 |
|
| 326 |
-
const
|
| 327 |
-
const html = await getHTML(url);
|
| 328 |
if (!html) { cacheSet(ck, []); return []; }
|
| 329 |
|
| 330 |
const $ = cheerio.load(html);
|
| 331 |
let raw = [];
|
|
|
|
| 332 |
|
| 333 |
-
|
| 334 |
-
const $el = $(el);
|
| 335 |
-
const rawText = _clean($el.text());
|
| 336 |
-
let detailHref = null;
|
| 337 |
-
$el.find('a').each((_, a) => {
|
| 338 |
-
const h = $(a).attr('href') || '';
|
| 339 |
-
if (h.includes(`/${lang}/`) && /\/\d+$/.test(h)) detailHref = h;
|
| 340 |
-
});
|
| 341 |
-
if (!detailHref) return;
|
| 342 |
-
const parts = detailHref.split('/');
|
| 343 |
-
const subId = parts[parts.length - 1];
|
| 344 |
-
if (!subId || !/^\d+$/.test(subId)) return;
|
| 345 |
-
|
| 346 |
-
const isGood = $el.find('.green-icon, .positive-icon').length > 0 ||
|
| 347 |
-
$el.hasClass('good') || ($el.attr('class') || '').toLowerCase().includes('good');
|
| 348 |
-
const comment = _clean($el.find('p.comment, .comment').first().text());
|
| 349 |
-
const releaseLines = $el.find('.r li, ul.release li, ul.scrolllist li, ul li')
|
| 350 |
-
.map((_, li) => $(li).text().trim()).get().filter(Boolean);
|
| 351 |
-
const releaseInfo = releaseLines.join(' | ').substring(0, 130) || _clean($el.text()).substring(0, 130);
|
| 352 |
-
if (isAISub(`${comment} ${releaseInfo}`)) return;
|
| 353 |
-
raw.push({ subId, detailUrl: detailHref.startsWith('http') ? detailHref : `${BASE}${detailHref}`,
|
| 354 |
-
isGood, releaseInfo, comment, authorNote: '', lang, goodVotes: 0, badVotes: 0, downloads: 0,
|
| 355 |
-
_rawText: rawText });
|
| 356 |
-
});
|
| 357 |
-
|
| 358 |
if (!raw.length) {
|
| 359 |
$('a[href]').each((_, a) => {
|
| 360 |
const href = $(a).attr('href') || '';
|
|
@@ -362,128 +351,143 @@ async function getSubsList(slug, lang, season, episode) {
|
|
| 362 |
const parts = href.split('/');
|
| 363 |
const subId = parts[parts.length - 1];
|
| 364 |
if (!subId || !/^\d+$/.test(subId)) return;
|
| 365 |
-
raw.push({ subId, detailUrl: href.startsWith('http') ? href : `${BASE}${href}`,
|
| 366 |
-
|
| 367 |
-
goodVotes: 0, badVotes: 0, downloads: 0, _rawText: '' });
|
| 368 |
});
|
| 369 |
}
|
| 370 |
|
| 371 |
-
// Episode filter
|
| 372 |
-
if (season && episode) {
|
| 373 |
-
const
|
| 374 |
-
|
| 375 |
-
|
|
|
|
|
|
|
|
|
|
| 376 |
});
|
| 377 |
-
if (
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
}
|
| 380 |
|
| 381 |
-
const seen
|
| 382 |
-
const unique
|
| 383 |
|
| 384 |
-
|
| 385 |
-
const
|
|
|
|
|
|
|
| 386 |
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
const OFFICIAL_SOURCES = ['netflix','nf','amzn','amazon','osn','shahid','wavo','hbo','hulu','disney','dsnp','appletv','atvp'];
|
| 395 |
-
|
| 396 |
-
function scoreTrack(sub, streamTitle) {
|
| 397 |
-
const text = (sub.releaseInfo || '').toLowerCase();
|
| 398 |
-
let s = 0;
|
| 399 |
-
|
| 400 |
-
if (OFFICIAL_SOURCES.some(src => text.includes(src))) s += 150;
|
| 401 |
-
if (/\b(synced|resync|corrected|fixed|proper|repack|retail|sync\.fix)\b/i.test(text)) s += 80;
|
| 402 |
-
if (/\b(sdh|hi\.|hi_|hearing.impaired)\b/i.test(text) || sub.hearingImpaired) s -= 40;
|
| 403 |
-
if (sub.rating) s += Math.round((sub.rating || 0) * 4);
|
| 404 |
-
if (/\.(ass|ssa)$/i.test(sub.releaseInfo || '')) s += 5;
|
| 405 |
-
|
| 406 |
-
// Release group match between subtitle and stream filename
|
| 407 |
-
const strmTitle = (streamTitle || '').toLowerCase();
|
| 408 |
-
const subGroups = text.match(/\b(?:-([a-z0-9]{2,20}))\b/g) || [];
|
| 409 |
-
for (const g of subGroups) {
|
| 410 |
-
const group = g.replace(/^-/, '');
|
| 411 |
-
if (group && strmTitle.includes(group)) { s += 100; break; }
|
| 412 |
-
}
|
| 413 |
|
| 414 |
-
|
|
|
|
| 415 |
}
|
| 416 |
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
.
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
if (
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
const
|
| 436 |
-
if (
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
)[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
}
|
| 442 |
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
return null;
|
| 461 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
|
| 463 |
-
/
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
app.get('/manifest.json', (req, res) => res.json({
|
| 469 |
-
id: 'org.van.vansubs.v4',
|
| 470 |
-
name: 'VanSubs+ (Subf2m) β Smart Slug',
|
| 471 |
-
description: 'Human-verified Arabic & English. AI-free. Candidate scoring for correct page.',
|
| 472 |
-
version: '4.0.0',
|
| 473 |
-
resources: ['subtitles'],
|
| 474 |
-
types: ['movie', 'series'],
|
| 475 |
-
idPrefixes: ['tt'],
|
| 476 |
-
catalogs: []
|
| 477 |
}));
|
| 478 |
|
| 479 |
-
app.get('/sub/:
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
if (!
|
| 483 |
-
const
|
| 484 |
-
|
| 485 |
-
res.setHeader('Content-
|
| 486 |
-
res.
|
|
|
|
| 487 |
});
|
| 488 |
|
| 489 |
app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
|
|
@@ -491,73 +495,86 @@ app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
|
|
| 491 |
const { type, id } = req.params;
|
| 492 |
if (!id?.startsWith('tt')) return res.json({ subtitles: [] });
|
| 493 |
|
| 494 |
-
const ck = `
|
| 495 |
-
const hit = cacheGet(ck);
|
| 496 |
if (hit) return res.json({ subtitles: hit });
|
| 497 |
|
| 498 |
try {
|
| 499 |
let imdbId = id, season = null, episode = null;
|
| 500 |
if (id.includes(':')) {
|
| 501 |
-
const
|
| 502 |
-
imdbId =
|
| 503 |
}
|
| 504 |
if (!/^tt\d+$/.test(imdbId)) return res.json({ subtitles: [] });
|
| 505 |
|
| 506 |
-
const extra = req.params.extra ? decodeURIComponent(req.params.extra.replace('.json','')) : '';
|
| 507 |
-
const streamTitle = (extra.match(/filename=([^&]+)/i) || [])[1] || '';
|
| 508 |
-
|
| 509 |
-
console.log(`\nββ [Subf2m v4] ${type.toUpperCase()} ${imdbId}` +
|
| 510 |
-
(season ? ` S${String(season).padStart(2,'0')}E${String(episode??0).padStart(2,'0')}` : '') +
|
| 511 |
-
(streamTitle ? `\nβ hint: "${streamTitle}"` : ''));
|
| 512 |
-
|
| 513 |
-
// TMDB metadata for candidate scoring
|
| 514 |
const tmdb = await getTMDB(imdbId);
|
| 515 |
-
const slug = await
|
| 516 |
-
if (!slug) return res.json({ subtitles: [] });
|
|
|
|
| 517 |
|
| 518 |
-
const [
|
| 519 |
getSubsList(slug, 'english', season, episode),
|
| 520 |
-
getSubsList(slug, 'arabic',
|
| 521 |
]);
|
| 522 |
|
| 523 |
-
const
|
| 524 |
-
|
| 525 |
-
.sort((a, b) => b.score - a.score)
|
| 526 |
-
.slice(0, MAX_PER_LANG);
|
| 527 |
-
|
| 528 |
-
const engTop = process(engSubs);
|
| 529 |
-
const araTop = process(araSubs);
|
| 530 |
-
|
| 531 |
-
// Parallel ZIP resolution
|
| 532 |
-
const resolveAll = async (arr) => {
|
| 533 |
-
const results = await Promise.all(arr.map(async (s) => {
|
| 534 |
-
const dl = `${s.detailUrl}/download`;
|
| 535 |
-
const resolved = await resolveUrl(dl, s.lang, season, episode);
|
| 536 |
-
if (!resolved) return null;
|
| 537 |
-
return { ...s, url: resolved.url, filename: resolved.extractedName || s.releaseInfo };
|
| 538 |
-
}));
|
| 539 |
-
return results.filter(Boolean);
|
| 540 |
-
};
|
| 541 |
-
|
| 542 |
-
const engFinal = await resolveAll(engTop);
|
| 543 |
-
const araFinal = await resolveAll(araTop);
|
| 544 |
|
| 545 |
-
const proto
|
| 546 |
-
const
|
| 547 |
-
const origin = `${proto}://${host}`;
|
| 548 |
|
| 549 |
-
const
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
|
|
|
|
|
|
| 553 |
|
|
|
|
|
|
|
| 554 |
cacheSet(ck, subtitles);
|
| 555 |
-
console.log(`β β
${subtitles.length} tracks (EN:${engFinal.length} AR:${araFinal.length})`);
|
| 556 |
res.json({ subtitles });
|
| 557 |
-
} catch (e) {
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
});
|
| 562 |
|
| 563 |
-
app.listen(PORT, '0.0.0.0', () => console.log(`
|
|
|
|
|
|
|
|
|
|
| 1 |
const express = require('express');
|
| 2 |
const cheerio = require('cheerio');
|
| 3 |
const AdmZip = require('adm-zip');
|
|
|
|
| 10 |
const PORT = process.env.PORT || 7860;
|
| 11 |
|
| 12 |
app.use((req, res, next) => {
|
| 13 |
+
res.header('Access-Control-Allow-Origin', '*');
|
| 14 |
res.header('Access-Control-Allow-Headers', '*');
|
| 15 |
res.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
| 16 |
if (req.method === 'OPTIONS') return res.sendStatus(200);
|
| 17 |
next();
|
| 18 |
});
|
| 19 |
|
| 20 |
+
// ββ CONFIG ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
| 21 |
const BASE = 'https://subf2m.co';
|
| 22 |
+
const TMDB_KEY = '30166e2db6b420d3f230808cd1bb2c90';
|
| 23 |
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
| 24 |
const MAX_PER_LANG = 4;
|
| 25 |
const CACHE_TTL = 6 * 60 * 60 * 1000;
|
| 26 |
const REQ_TIMEOUT = 15_000;
|
| 27 |
const DL_TIMEOUT = 25_000;
|
| 28 |
+
const MAX_ENRICH = 30;
|
| 29 |
+
|
| 30 |
+
const ORDINALS = [
|
| 31 |
+
'', 'first', 'second', 'third', 'fourth', 'fifth',
|
| 32 |
+
'sixth', 'seventh', 'eighth', 'ninth', 'tenth',
|
| 33 |
+
'eleventh', 'twelfth', 'thirteenth', 'fourteenth', 'fifteenth'
|
| 34 |
+
];
|
| 35 |
|
| 36 |
+
// Languages we ALLOW (everything else is silently skipped)
|
| 37 |
const ALLOWED_LANGS = new Set(['english', 'arabic']);
|
| 38 |
|
| 39 |
+
// Other-language path fragments β used to drop mis-classified links
|
| 40 |
+
const OTHER_LANGS = [
|
| 41 |
+
'french','spanish','portuguese','german','italian','turkish','persian','farsi',
|
| 42 |
+
'hindi','russian','polish','dutch','swedish','norwegian','danish','finnish',
|
| 43 |
+
'korean','japanese','chinese','malay','indonesian','vietnamese','thai','greek',
|
| 44 |
+
'hungarian','czech','romanian','bulgarian','hebrew','ukrainian','serbian',
|
| 45 |
+
'croatian','slovak','slovenian','latvian','estonian','lithuanian','albanian',
|
| 46 |
+
'azerbaijani','bosnian','catalan','georgian','icelandic','macedonian','mongolian',
|
| 47 |
+
'punjabi','sinhala','tamil','bengali','urdu','nepali','khmer','burmese',
|
| 48 |
+
'afrikaans','swahili'
|
|
|
|
| 49 |
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
+
// ββ STREAMING PLATFORM PRIORITY ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 52 |
+
// Streaming-ripped subs are almost always perfectly synced retail encodes.
|
| 53 |
+
// Prioritise them over scene / BluRay rips which can be off by seconds.
|
| 54 |
+
const STREAMING_TIERS = [
|
| 55 |
+
{ pts: 40, rx: [/\bnflx\b/i, /\bnetflix\b/i] },
|
| 56 |
+
{ pts: 38, rx: [/\bamzn\b/i, /\bamazon\b/i, /\bprime\b/i] },
|
| 57 |
+
{ pts: 36, rx: [/\bdsnp\b/i, /\bdisney\+?/i] },
|
| 58 |
+
{ pts: 35, rx: [/\batvp\b/i, /\bappletv\b/i] },
|
| 59 |
+
{ pts: 34, rx: [/\bhbo\b/i, /\bhbomax\b/i] },
|
| 60 |
+
{ pts: 33, rx: [/\bpcok\b/i, /\bpeacock\b/i] },
|
| 61 |
+
{ pts: 32, rx: [/\bhulu\b/i] },
|
| 62 |
+
{ pts: 31, rx: [/\bpmtp\b/i, /\bparamount/i] },
|
| 63 |
+
{ pts: 28, rx: [/\bwavve\b/i, /\btving\b/i, /\bwatcha\b/i, /\bviafree\b/i] },
|
| 64 |
+
{ pts: 20, rx: [/\bweb[- ]?dl\b/i, /\bwebrip\b/i] },
|
| 65 |
+
{ pts: 14, rx: [/\bbluray\b/i, /\bblu[- ]ray\b/i, /\bbdrip\b/i, /\b1080p\b/i, /\b4k\b/i] },
|
| 66 |
+
{ pts: 12, rx: [/\bhdtv\b/i, /\b720p\b/i] },
|
| 67 |
+
{ pts: 8, rx: [/\bproper\b/i, /\brepack\b/i, /\bsynced\b/i, /\bsync\b/i, /\bfixed\b/i] },
|
| 68 |
+
];
|
| 69 |
+
|
| 70 |
+
function streamScore(text) {
|
| 71 |
+
if (!text) return 0;
|
| 72 |
+
let best = 0;
|
| 73 |
+
for (const t of STREAMING_TIERS)
|
| 74 |
+
if (t.rx.some(r => r.test(text))) best = Math.max(best, t.pts);
|
| 75 |
+
return best;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// ββ AI SUBTITLE DETECTION ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 79 |
+
const AI_QUICK = [
|
| 80 |
+
'a.i','chatgpt','deepl','whisper','openai','gemini','claude','llama',
|
| 81 |
+
'machine translat','auto translat','auto-translat','auto generat','autosub',
|
| 82 |
+
'neural','yandex','bing translat','google translat','amazon translat',
|
| 83 |
+
'microsoft translat','nmt','artificial intel',' ai sub','ai-sub',
|
| 84 |
+
'ai translat','translated by ai','generated by ai',
|
| 85 |
+
];
|
| 86 |
+
const AI_RX = [
|
| 87 |
+
/\ba\.i\.\b/i, /\bartificial[\s\-_]intelligence/i,
|
| 88 |
+
/\b(ai|a\.i)[\s\-_](sub(title)?s?|translat|generat|correct|made|creat|caption|dub|edit|sync|fix)/i,
|
| 89 |
+
/(sub(title)?s?|translat|generat|creat|made|dub|caption|correct)\s+(by\s+)?(ai|a\.i)\b/i,
|
| 90 |
+
/\b(ai|a\.i)[- _]?(powered|assisted|based|driven|enabled)\b/i,
|
| 91 |
+
/machine[\s\-_]?translat/i, /auto[\s\-_]?translat/i, /auto[\s\-_]?sub(title)?s?\b/i,
|
| 92 |
+
/\bautosub\b/i, /\bnmt\b/, /neural[\s\-_]?(machine[\s\-_]?)?translat/i,
|
| 93 |
+
/\bwhisper[\s\-_]?(cpp|x|large|medium|small|tiny|base|v[123])?\b/i,
|
| 94 |
+
/\bfaster[\s\-_]?whisper\b/i, /\bwhisperx\b/i,
|
| 95 |
+
/\bopenai\b/i, /\bchatgpt\b/i,
|
| 96 |
+
/\bgpt[\s\-_]?[1-9o]?[\s\-_]?(turbo|mini|nano|ultra|pro|plus|omni|preview|vision)?\b/i,
|
| 97 |
+
/\banthropic\b/i, /\bclaude[\s\-_]?(2|3|4|sonnet|haiku|opus|instant|v\d)?\b/i,
|
| 98 |
+
/\bgemini[\s\-_]?(pro|ultra|flash|nano|advanced|1\.5|2\.0)?\b/i,
|
| 99 |
+
/\bgoogle[\s\-_]?(bard|translat|speech|asr)\b/i, /\bbard\b/i,
|
| 100 |
+
/\bllama[\s\-_]?[23]?\b/i, /\bmeta[\s\-_]?ai\b/i,
|
| 101 |
+
/\bdeepseek[\s\-_]?(v[123]|r1)?\b/i, /\bdeep[\s\-_]?l\b/i,
|
| 102 |
+
/\bmistral\b/i, /\bmixtral\b/i, /\bgrok[\s\-_]?[12]?\b/i,
|
| 103 |
+
/\bbing[\s\-_]?translat\b/i, /\bcopilot\b/i,
|
| 104 |
+
/\byandex[\s\-_]?(translat|gpt)\b/i, /\bamazon[\s\-_]?(translat|titan)\b/i,
|
| 105 |
+
/\bvosk\b/i, /\bassemblyai\b/i, /\bspeech[\s\-_]?(recogni|to[\s\-_]text)/i,
|
| 106 |
+
/\bsubtitlebee\b/i, /\bmaestra\b/i, /\bzubtitle\b/i, /\bsubmagic\b/i,
|
| 107 |
+
/\bhappy[\s\-_]?scribe\b/i, /\bdescript\b/i, /\bkapwing\b/i,
|
| 108 |
+
/\bveed[\s\-_]?io\b/i, /\bsubanana\b/i,
|
| 109 |
+
];
|
| 110 |
+
function isAI(text) {
|
| 111 |
+
if (!text) return false;
|
| 112 |
+
const lo = text.toLowerCase();
|
| 113 |
+
if (!AI_QUICK.some(q => lo.includes(q)) && !lo.includes(' ai') && !lo.includes('ai ') && !lo.includes('ai-')) return false;
|
| 114 |
+
return AI_RX.some(r => r.test(text));
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// ββ HTTP CORE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 118 |
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';
|
| 119 |
|
| 120 |
+
function _req(urlStr, opts = {}) {
|
| 121 |
return new Promise((resolve, reject) => {
|
| 122 |
+
const parsed = new URL(urlStr);
|
| 123 |
+
const lib = parsed.protocol === 'https:' ? https : http;
|
| 124 |
+
const ro = {
|
|
|
|
| 125 |
hostname: parsed.hostname,
|
| 126 |
port : parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
| 127 |
path : parsed.pathname + parsed.search,
|
| 128 |
method : 'GET',
|
| 129 |
headers : {
|
| 130 |
+
'User-Agent' : UA,
|
| 131 |
+
'Accept' : opts.binary ? 'application/zip,*/*' : 'text/html,*/*;q=0.8',
|
| 132 |
+
'Accept-Language' : 'en-US,en;q=0.9',
|
| 133 |
+
'Accept-Encoding' : 'gzip, deflate',
|
| 134 |
+
'Referer' : BASE + '/',
|
| 135 |
+
'Connection' : 'keep-alive',
|
|
|
|
| 136 |
...(opts.headers || {})
|
| 137 |
}
|
| 138 |
};
|
| 139 |
+
const req = lib.request(ro, res => {
|
| 140 |
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
| 141 |
+
return _req(new URL(res.headers.location, urlStr).toString(), opts).then(resolve).catch(reject);
|
|
|
|
| 142 |
}
|
| 143 |
if (res.statusCode === 404) return resolve(null);
|
| 144 |
if (res.statusCode !== 200) return resolve(null);
|
| 145 |
const chunks = [];
|
| 146 |
+
let stream = res;
|
| 147 |
const enc = res.headers['content-encoding'] || '';
|
| 148 |
if (enc.includes('gzip')) stream = res.pipe(zlib.createGunzip());
|
| 149 |
else if (enc.includes('br')) stream = res.pipe(zlib.createBrotliDecompress());
|
|
|
|
| 152 |
stream.on('end', () => resolve(opts.binary ? Buffer.concat(chunks) : Buffer.concat(chunks).toString('utf8')));
|
| 153 |
stream.on('error', reject);
|
| 154 |
});
|
| 155 |
+
req.setTimeout(opts.timeout || REQ_TIMEOUT, () => { req.destroy(); reject(new Error('timeout')); });
|
| 156 |
req.on('error', reject);
|
| 157 |
req.end();
|
| 158 |
});
|
| 159 |
}
|
|
|
|
| 160 |
async function getHTML(url, tries = 3) {
|
| 161 |
for (let i = 0; i < tries; i++) {
|
| 162 |
+
try { const r = await _req(url); if (r !== null) return r; }
|
| 163 |
+
catch (e) { if (i < tries - 1) await sleep(1200 * (i + 1)); }
|
| 164 |
}
|
| 165 |
return null;
|
| 166 |
}
|
| 167 |
+
async function getBin(url, tries = 3) {
|
|
|
|
| 168 |
for (let i = 0; i < tries; i++) {
|
| 169 |
+
try { const r = await _req(url, { binary: true, timeout: DL_TIMEOUT }); if (r !== null) return r; }
|
| 170 |
+
catch (e) { if (i < tries - 1) await sleep(1200 * (i + 1)); }
|
| 171 |
}
|
| 172 |
return null;
|
| 173 |
}
|
| 174 |
|
| 175 |
+
// ββ CACHE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
const _cache = new Map();
|
| 177 |
+
function cacheGet(k) {
|
| 178 |
+
const item = _cache.get(k);
|
|
|
|
| 179 |
if (!item) return null;
|
| 180 |
+
if (Date.now() - item.ts > CACHE_TTL) { _cache.delete(k); return null; }
|
| 181 |
return item.val;
|
| 182 |
}
|
| 183 |
+
function cacheSet(k, v) {
|
| 184 |
+
if (_cache.size >= 800) { let n = 0; for (const key of _cache.keys()) { _cache.delete(key); if (++n >= 80) break; } }
|
| 185 |
+
_cache.set(k, { val: v, ts: Date.now() });
|
| 186 |
}
|
| 187 |
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
| 188 |
+
const _clean = t => (t || '').replace(/\s+/g, ' ').trim();
|
| 189 |
|
| 190 |
+
// ββ TMDB βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
| 191 |
async function getTMDB(imdbId) {
|
| 192 |
+
const ck = `tmdb:${imdbId}`, hit = cacheGet(ck);
|
|
|
|
| 193 |
if (hit !== null) return hit;
|
| 194 |
try {
|
| 195 |
+
const raw = await _req(`${TMDB_BASE}/find/${imdbId}?external_source=imdb_id&api_key=${TMDB_KEY}`, { timeout: 8000 });
|
| 196 |
if (!raw) { cacheSet(ck, null); return null; }
|
| 197 |
const d = JSON.parse(raw);
|
| 198 |
let info = null;
|
| 199 |
if (d.movie_results?.length) {
|
| 200 |
const m = d.movie_results[0];
|
| 201 |
+
info = { title: m.title || m.original_title, year: m.release_date?.slice(0,4) || null, tmdbId: m.id, type: 'movie' };
|
| 202 |
} else if (d.tv_results?.length) {
|
| 203 |
const t = d.tv_results[0];
|
| 204 |
+
info = { title: t.name || t.original_name, year: t.first_air_date?.slice(0,4) || null, tmdbId: t.id, type: 'series' };
|
| 205 |
}
|
| 206 |
+
cacheSet(ck, info); return info;
|
|
|
|
| 207 |
} catch { cacheSet(ck, null); return null; }
|
| 208 |
}
|
| 209 |
|
| 210 |
+
// ββ SLUG RESOLUTION: IMDb ID β subf2m collection slug ββββββββββββββββββββββββ
|
| 211 |
+
// Flow confirmed by Bazarr/community research:
|
| 212 |
+
// subf2m.co/subtitles/searchbytitle?query=tt{id}&l=
|
| 213 |
+
// returns results whose <a> text is "Show Name - First Season (2017)"
|
| 214 |
+
// We pick the entry whose text matches the target season (or movie year).
|
| 215 |
+
async function resolveSlug(imdbId, type, season, tmdb) {
|
| 216 |
+
const ck = `f2mslug:${imdbId}:${season || 0}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
const hit = cacheGet(ck);
|
| 218 |
if (hit !== null) return hit;
|
| 219 |
|
| 220 |
+
// Pass 1: search by IMDb ID (most reliable β works for all content globally)
|
| 221 |
+
let slug = await _f2mSearch(imdbId, type, season, tmdb);
|
| 222 |
+
// Pass 2: search by English title (fallback for obscure content)
|
| 223 |
+
if (!slug && tmdb?.title) slug = await _f2mSearch(tmdb.title, type, season, tmdb);
|
| 224 |
+
|
| 225 |
+
cacheSet(ck, slug);
|
| 226 |
+
return slug;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
async function _f2mSearch(query, type, season, tmdb) {
|
| 230 |
+
const url = `${BASE}/subtitles/searchbytitle?query=${encodeURIComponent(query)}&l=`;
|
| 231 |
const html = await getHTML(url);
|
| 232 |
+
if (!html) return null;
|
| 233 |
|
| 234 |
const $ = cheerio.load(html);
|
| 235 |
+
const results = [];
|
| 236 |
+
const seen = new Set();
|
| 237 |
|
| 238 |
+
$('a[href]').each((_, el) => {
|
| 239 |
+
const href = ($(el).attr('href') || '').trim();
|
| 240 |
+
if (!href.startsWith('/subtitles/') || href.includes('searchbytitle')) return;
|
| 241 |
const parts = href.split('/').filter(Boolean);
|
| 242 |
if (parts.length < 2) return;
|
| 243 |
const slug = parts[1];
|
| 244 |
+
if (!slug || seen.has(slug)) return;
|
| 245 |
+
seen.add(slug);
|
| 246 |
+
const text = ($(el).text() || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
| 247 |
+
if (!text) return;
|
| 248 |
+
results.push({ slug, text });
|
| 249 |
});
|
| 250 |
|
| 251 |
+
if (!results.length) return null;
|
| 252 |
+
return type === 'series' && season
|
| 253 |
+
? _pickSeries(results, tmdb, season)
|
| 254 |
+
: _pickMovie(results, tmdb);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
function _pickSeries(results, tmdb, season) {
|
| 258 |
+
const ord = (ORDINALS[season] || '').toLowerCase();
|
| 259 |
+
const s2 = String(season).padStart(2, '0');
|
| 260 |
+
const year = tmdb?.year || '';
|
| 261 |
+
// First word of the show's slug β used as a guard against unrelated results
|
| 262 |
+
const guard = tmdb?.title
|
| 263 |
+
? tmdb.title.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'')
|
| 264 |
+
.replace(/[^a-z0-9]/g, ' ').trim().split(' ')[0]
|
| 265 |
+
: '';
|
| 266 |
+
|
| 267 |
+
let best = null, bestScore = -1;
|
| 268 |
+
|
| 269 |
+
for (const r of results) {
|
| 270 |
+
const t = r.text;
|
| 271 |
+
const s = r.slug.toLowerCase();
|
| 272 |
+
let score = 0;
|
| 273 |
+
|
| 274 |
+
// Guard: result must at least start with the show's first word
|
| 275 |
+
if (guard && !s.startsWith(guard) && !t.startsWith(guard)) continue;
|
| 276 |
+
|
| 277 |
+
// Ordinal word match (strongest: "first season", "second season")
|
| 278 |
+
if (ord) {
|
| 279 |
+
if (t.includes(`${ord} season`) || t.includes(`${ord} series`)) score += 100;
|
| 280 |
+
if (s.includes(`-${ord}-season`) || s.includes(`${ord}-season`)) score += 90;
|
|
|
|
|
|
|
| 281 |
}
|
| 282 |
+
// Numeric season
|
| 283 |
+
if (t.includes(`season ${season}`) || s.includes(`season-${season}`)) score += 80;
|
| 284 |
+
if (s.includes(`-s${s2}`)) score += 75;
|
| 285 |
+
// Year
|
| 286 |
+
if (year && (t.includes(`(${year})`) || s.includes(year))) score += 20;
|
| 287 |
+
// Has "season" keyword at all
|
| 288 |
+
if (t.includes('season') || s.includes('season')) score += 5;
|
| 289 |
+
|
| 290 |
+
if (score > bestScore) { bestScore = score; best = r; }
|
| 291 |
}
|
| 292 |
|
| 293 |
+
// Must have at least a "season" keyword β avoids picking unrelated movie entries
|
| 294 |
+
if (best && bestScore >= 5) {
|
| 295 |
+
console.log(`[f2m-slug] S${season} β "${best.slug}" (score ${bestScore})`);
|
| 296 |
+
return best.slug;
|
| 297 |
+
}
|
| 298 |
+
// Absolute fallback: first result whose slug starts with guard word
|
| 299 |
+
const fb = guard ? results.find(r => r.slug.toLowerCase().startsWith(guard)) : null;
|
| 300 |
+
if (fb) console.log(`[f2m-slug] S${season} fallback β "${fb.slug}"`);
|
| 301 |
+
return fb ? fb.slug : null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
}
|
| 303 |
|
| 304 |
+
function _pickMovie(results, tmdb) {
|
| 305 |
+
if (!results.length) return null;
|
| 306 |
+
const year = tmdb?.year || '';
|
| 307 |
+
const guard = tmdb?.title
|
| 308 |
+
? tmdb.title.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'')
|
| 309 |
+
.replace(/[^a-z0-9]/g, ' ').trim().split(' ')[0]
|
| 310 |
+
: '';
|
| 311 |
+
|
| 312 |
+
let best = null, bestScore = -1;
|
| 313 |
+
for (const r of results) {
|
| 314 |
+
const t = r.text;
|
| 315 |
+
const s = r.slug.toLowerCase();
|
| 316 |
+
let score = 0;
|
| 317 |
+
|
| 318 |
+
// Penalise series entries hard
|
| 319 |
+
if (t.includes('season') || s.includes('season')) { score -= 100; }
|
| 320 |
+
if (guard && s.startsWith(guard)) score += 40;
|
| 321 |
+
if (year && (t.includes(`(${year})`) || s.includes(year))) score += 50;
|
| 322 |
+
|
| 323 |
+
if (score > bestScore) { bestScore = score; best = r; }
|
| 324 |
+
}
|
| 325 |
+
if (best) {
|
| 326 |
+
console.log(`[f2m-slug] movie β "${best.slug}" (score ${bestScore})`);
|
| 327 |
+
return best.slug;
|
| 328 |
+
}
|
| 329 |
+
return results[0].slug;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
}
|
| 331 |
|
| 332 |
+
// ββ SUBTITLE LIST βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 333 |
async function getSubsList(slug, lang, season, episode) {
|
| 334 |
if (!ALLOWED_LANGS.has(lang)) return [];
|
| 335 |
+
const ck = `f2mlist:${slug}:${lang}:${episode || 0}`;
|
|
|
|
| 336 |
const hit = cacheGet(ck);
|
| 337 |
if (hit) return hit;
|
| 338 |
|
| 339 |
+
const html = await getHTML(`${BASE}/subtitles/${slug}/${lang}`);
|
|
|
|
| 340 |
if (!html) { cacheSet(ck, []); return []; }
|
| 341 |
|
| 342 |
const $ = cheerio.load(html);
|
| 343 |
let raw = [];
|
| 344 |
+
$('li.item').each((_, el) => _extractItem($, el, lang, raw));
|
| 345 |
|
| 346 |
+
// Fallback scraper for when li.item isn't populated
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
if (!raw.length) {
|
| 348 |
$('a[href]').each((_, a) => {
|
| 349 |
const href = $(a).attr('href') || '';
|
|
|
|
| 351 |
const parts = href.split('/');
|
| 352 |
const subId = parts[parts.length - 1];
|
| 353 |
if (!subId || !/^\d+$/.test(subId)) return;
|
| 354 |
+
raw.push({ subId, lang, detailUrl: href.startsWith('http') ? href : `${BASE}${href}`,
|
| 355 |
+
isGood: false, releaseInfo: '', comment: '', authorNote: '', goodVotes: 0, badVotes: 0, _rawText: '' });
|
|
|
|
| 356 |
});
|
| 357 |
}
|
| 358 |
|
| 359 |
+
// Episode filter for series
|
| 360 |
+
if (season != null && episode != null) {
|
| 361 |
+
const s2 = String(season).padStart(2, '0');
|
| 362 |
+
const ep2 = String(episode).padStart(2, '0');
|
| 363 |
+
const pats = [`s${s2}e${ep2}`, `${season}x${ep2}`, ` e${ep2}`, `ep${ep2}`, `episode ${episode}`, `- ${episode} -`];
|
| 364 |
+
const filtered = raw.filter(it => {
|
| 365 |
+
const txt = (it._rawText || it.releaseInfo || '').toLowerCase();
|
| 366 |
+
return pats.some(p => txt.includes(p));
|
| 367 |
});
|
| 368 |
+
if (filtered.length) {
|
| 369 |
+
raw = filtered;
|
| 370 |
+
console.log(`[f2m-ep] S${season}E${episode}: filtered to ${raw.length}`);
|
| 371 |
+
} else {
|
| 372 |
+
console.log(`[f2m-ep] S${season}E${episode}: no exact match, keeping all ${raw.length}`);
|
| 373 |
+
}
|
| 374 |
}
|
| 375 |
|
| 376 |
+
const seen = new Set();
|
| 377 |
+
const unique = raw.filter(s => { if (seen.has(s.subId)) return false; seen.add(s.subId); return true; });
|
| 378 |
|
| 379 |
+
// Enrich detail pages in parallel (batched to avoid hammering the server)
|
| 380 |
+
const toEnrich = unique.slice(0, MAX_ENRICH);
|
| 381 |
+
const enriched = (await Promise.all(toEnrich.map(s => _enrich(s)))).filter(Boolean);
|
| 382 |
+
const human = enriched.filter(s => !isAI(`${s.releaseInfo} ${s.comment} ${s.authorNote}`));
|
| 383 |
|
| 384 |
+
// Sort: streaming platform first, then isGood, then raw quality
|
| 385 |
+
human.sort((a, b) => {
|
| 386 |
+
const sa = streamScore(a.releaseInfo), sb = streamScore(b.releaseInfo);
|
| 387 |
+
if (sa !== sb) return sb - sa;
|
| 388 |
+
const ga = a.isGood ? 1 : 0, gb = b.isGood ? 1 : 0;
|
| 389 |
+
return gb - ga;
|
| 390 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
|
| 392 |
+
cacheSet(ck, human);
|
| 393 |
+
return human;
|
| 394 |
}
|
| 395 |
|
| 396 |
+
function _extractItem($, el, lang, out) {
|
| 397 |
+
const $el = $(el);
|
| 398 |
+
const rawText = _clean($el.text());
|
| 399 |
+
let detailHref = null;
|
| 400 |
+
$el.find('a').each((_, a) => {
|
| 401 |
+
const h = $(a).attr('href') || '';
|
| 402 |
+
if (h.includes(`/${lang}/`) && /\/\d+$/.test(h)) detailHref = h;
|
| 403 |
+
});
|
| 404 |
+
if (!detailHref) return;
|
| 405 |
+
if (OTHER_LANGS.some(ol => detailHref.toLowerCase().includes(`/${ol}/`))) return;
|
| 406 |
+
const parts = detailHref.split('/');
|
| 407 |
+
const subId = parts[parts.length - 1];
|
| 408 |
+
if (!subId || !/^\d+$/.test(subId)) return;
|
| 409 |
+
const isGood = $el.find('.green-icon,.positive-icon').length > 0 ||
|
| 410 |
+
$el.find('img[alt="good"],img[alt="Positive"],img[alt="positive"]').length > 0 ||
|
| 411 |
+
($el.attr('class') || '').toLowerCase().includes('good');
|
| 412 |
+
const comment = _clean($el.find('p.comment,.comment').first().text());
|
| 413 |
+
const relLines = $el.find('.r li,ul.release li,ul.scrolllist li,ul li')
|
| 414 |
+
.map((_, li) => $(li).text().trim()).get().filter(Boolean);
|
| 415 |
+
const releaseInfo = (relLines.join(' | ') || _clean($el.text())).substring(0, 130);
|
| 416 |
+
if (isAI(`${comment} ${releaseInfo}`)) return;
|
| 417 |
+
out.push({ subId, lang, isGood, releaseInfo, comment, authorNote: '', goodVotes: 0, badVotes: 0,
|
| 418 |
+
detailUrl: detailHref.startsWith('http') ? detailHref : `${BASE}${detailHref}`, _rawText: rawText });
|
| 419 |
+
}
|
| 420 |
|
| 421 |
+
async function _enrich(sub) {
|
| 422 |
+
const ck = `f2mdet:${sub.subId}`, hit = cacheGet(ck);
|
| 423 |
+
if (hit !== null) { if (hit.__blocked) return null; return { ...sub, ...hit }; }
|
| 424 |
+
const html = await getHTML(sub.detailUrl);
|
| 425 |
+
if (!html) return sub;
|
| 426 |
+
const $ = cheerio.load(html);
|
| 427 |
+
const txt = $.root().text();
|
| 428 |
+
const goodVotes = +(txt.match(/voted\s+as\s+good\s+by[:\s]*(\d+)/i)?.[1] ?? 0);
|
| 429 |
+
const badVotes = +(txt.match(/voted\s+as\s+bad\s+by[:\s]*(\d+)/i)?.[1] ?? 0);
|
| 430 |
+
const total = goodVotes + badVotes;
|
| 431 |
+
const ratio = total >= 3 ? goodVotes / total : null;
|
| 432 |
+
const isGood = sub.isGood || (ratio !== null && ratio >= 0.70 && goodVotes >= 2);
|
| 433 |
+
const authorNote = _clean($('p.comment,.comment,.notes,.author-comment').first().text());
|
| 434 |
+
if (isAI(`${authorNote} ${sub.releaseInfo} ${sub.comment}`)) { cacheSet(ck, { __blocked: true }); return null; }
|
| 435 |
+
const relLines = $('ul.release li,.release-names li,ul.scrolllist li').map((_, e) => $(e).text().trim()).get();
|
| 436 |
+
const releaseInfo = relLines.length ? relLines.join(' | ').substring(0, 130) : sub.releaseInfo;
|
| 437 |
+
const result = { releaseInfo, goodVotes, badVotes, isGood, authorNote };
|
| 438 |
+
cacheSet(ck, result);
|
| 439 |
+
return { ...sub, ...result };
|
| 440 |
}
|
| 441 |
|
| 442 |
+
// ββ SRT EXTRACTION βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 443 |
+
async function fetchSRT(dlUrl) {
|
| 444 |
+
const ck = `f2msrt:${dlUrl}`, hit = cacheGet(ck);
|
| 445 |
+
if (hit !== null) return hit;
|
| 446 |
+
try {
|
| 447 |
+
const buf = await getBin(dlUrl);
|
| 448 |
+
if (!buf) return null;
|
| 449 |
+
const content = _fromZip(buf) ?? _direct(buf);
|
| 450 |
+
if (!content) return null;
|
| 451 |
+
cacheSet(ck, content); return content;
|
| 452 |
+
} catch { return null; }
|
| 453 |
+
}
|
| 454 |
+
function _fromZip(buf) {
|
| 455 |
+
try {
|
| 456 |
+
const RANK = { '.srt': 0, '.vtt': 1, '.ass': 2, '.ssa': 3 };
|
| 457 |
+
const zip = new AdmZip(buf);
|
| 458 |
+
const files = zip.getEntries()
|
| 459 |
+
.filter(e => { const n = e.entryName.toLowerCase(); return !e.isDirectory && !n.startsWith('__macosx') && Object.keys(RANK).some(x => n.endsWith(x)); })
|
| 460 |
+
.sort((a, b) => (RANK[_ext(a.entryName)] ?? 9) - (RANK[_ext(b.entryName)] ?? 9));
|
| 461 |
+
for (const f of files) { const t = _decode(zip.readFile(f)); if (_isSub(t)) return t; }
|
| 462 |
+
} catch {}
|
| 463 |
return null;
|
| 464 |
}
|
| 465 |
+
function _direct(buf) { const t = _decode(buf); return _isSub(t) ? t : null; }
|
| 466 |
+
const _ext = n => (n.match(/(\.[^.]+)$/)?.[1]?.toLowerCase() ?? '');
|
| 467 |
+
const _isSub = t => typeof t === 'string' && t.includes('-->') && t.length > 50;
|
| 468 |
+
function _decode(buf) {
|
| 469 |
+
if (!buf) return '';
|
| 470 |
+
const hasBOM = buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF;
|
| 471 |
+
const u8 = buf.slice(hasBOM ? 3 : 0).toString('utf8');
|
| 472 |
+
return _isSub(u8) ? u8 : buf.toString('latin1');
|
| 473 |
+
}
|
| 474 |
|
| 475 |
+
// ββ ROUTES βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 476 |
+
app.get('/manifest.json', (_, res) => res.json({
|
| 477 |
+
id: 'org.van.vanstreams.plus.primary', name: 'VanStreams+',
|
| 478 |
+
description: 'Human-Verified Arabic & English subtitles. Anti-AI.',
|
| 479 |
+
version: '2.0.0', resources: ['subtitles'], types: ['movie','series'], idPrefixes: ['tt'], catalogs: []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
}));
|
| 481 |
|
| 482 |
+
app.get('/sub/:enc.srt', async (req, res) => {
|
| 483 |
+
let dlUrl = '';
|
| 484 |
+
try { dlUrl = Buffer.from(req.params.enc, 'base64url').toString('utf8'); } catch { return res.status(400).end(); }
|
| 485 |
+
if (!dlUrl.startsWith('https://subf2m.co/') || !dlUrl.endsWith('/download')) return res.status(403).end();
|
| 486 |
+
const content = await fetchSRT(dlUrl);
|
| 487 |
+
if (!content) return res.status(404).end();
|
| 488 |
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
| 489 |
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
| 490 |
+
res.end(content);
|
| 491 |
});
|
| 492 |
|
| 493 |
app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => {
|
|
|
|
| 495 |
const { type, id } = req.params;
|
| 496 |
if (!id?.startsWith('tt')) return res.json({ subtitles: [] });
|
| 497 |
|
| 498 |
+
const ck = `f2mresult:${id}`, hit = cacheGet(ck);
|
|
|
|
| 499 |
if (hit) return res.json({ subtitles: hit });
|
| 500 |
|
| 501 |
try {
|
| 502 |
let imdbId = id, season = null, episode = null;
|
| 503 |
if (id.includes(':')) {
|
| 504 |
+
const p = id.split(':');
|
| 505 |
+
imdbId = p[0]; season = p[1] ? +p[1] : null; episode = p[2] ? +p[2] : null;
|
| 506 |
}
|
| 507 |
if (!/^tt\d+$/.test(imdbId)) return res.json({ subtitles: [] });
|
| 508 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
const tmdb = await getTMDB(imdbId);
|
| 510 |
+
const slug = await resolveSlug(imdbId, type, season, tmdb);
|
| 511 |
+
if (!slug) { console.log(`[f2m] no slug for ${imdbId}`); return res.json({ subtitles: [] }); }
|
| 512 |
+
console.log(`[f2m] ${type} ${imdbId} S${season}E${episode} β ${slug}`);
|
| 513 |
|
| 514 |
+
const [engRes, araRes] = await Promise.allSettled([
|
| 515 |
getSubsList(slug, 'english', season, episode),
|
| 516 |
+
getSubsList(slug, 'arabic', season, episode)
|
| 517 |
]);
|
| 518 |
|
| 519 |
+
const engSubs = (engRes.status === 'fulfilled' ? engRes.value : []).slice(0, MAX_PER_LANG);
|
| 520 |
+
const araSubs = (araRes.status === 'fulfilled' ? araRes.value : []).slice(0, MAX_PER_LANG);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
|
| 522 |
+
const proto = req.headers['x-forwarded-proto'] || (req.socket?.encrypted ? 'https' : 'http');
|
| 523 |
+
const origin = `${proto}://${req.headers['x-forwarded-host'] || req.headers.host}`;
|
|
|
|
| 524 |
|
| 525 |
+
const build = (subs, code, flag) => subs.map(s => {
|
| 526 |
+
const enc = Buffer.from(`${s.detailUrl}/download`).toString('base64url');
|
| 527 |
+
const star = s.isGood ? ' β' : '';
|
| 528 |
+
const name = `${flag}${star} ${s.releaseInfo}`.trim().substring(0, 100);
|
| 529 |
+
return { id: `vsub-${code}-${s.subId}`, url: `${origin}/sub/${enc}.srt`, lang: code, name };
|
| 530 |
+
});
|
| 531 |
|
| 532 |
+
// Arabic first β Stremio auto-selects [0] as default
|
| 533 |
+
const subtitles = [...build(araSubs, 'ara', 'πΈπ¦ Arabic'), ...build(engSubs, 'eng', 'πΊπΈ English')];
|
| 534 |
cacheSet(ck, subtitles);
|
|
|
|
| 535 |
res.json({ subtitles });
|
| 536 |
+
} catch (e) { console.error('[f2m route]', e); res.json({ subtitles: [] }); }
|
| 537 |
+
});
|
| 538 |
+
|
| 539 |
+
app.get('/', (req, res) => {
|
| 540 |
+
const manifestUrl = `${req.protocol}://${req.get('host')}/manifest.json`;
|
| 541 |
+
res.type('html').send(`<!DOCTYPE html>
|
| 542 |
+
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 543 |
+
<title>VanStreams+ SubF2M</title>
|
| 544 |
+
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
| 545 |
+
body{font-family:-apple-system,'Segoe UI',system-ui,sans-serif;background:#050810;color:#dde6f0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:28px 20px;background-image:radial-gradient(ellipse 70% 40% at 50% 0%,rgba(0,180,255,.07) 0%,transparent 70%)}
|
| 546 |
+
.wrap{width:100%;max-width:560px;display:flex;flex-direction:column;gap:20px}
|
| 547 |
+
.logo{font-size:2.6rem;font-weight:800;letter-spacing:-1px}.logo .van{color:#00c6ff}.logo .subs{color:#fff}
|
| 548 |
+
.badge-row{display:flex;gap:8px;flex-wrap:wrap}
|
| 549 |
+
.badge{display:inline-flex;align-items:center;gap:5px;background:rgba(0,198,255,.08);border:1px solid rgba(0,198,255,.18);color:#fff;padding:4px 11px;border-radius:100px;font-size:.72rem;font-weight:700;letter-spacing:.4px;text-transform:uppercase}
|
| 550 |
+
.badge.green{background:rgba(0,220,130,.08);border-color:rgba(0,220,130,.18);color:#00dc82}
|
| 551 |
+
.desc{color:#fff;font-size:.9rem;line-height:1.6;margin-top:2px}
|
| 552 |
+
.card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:16px;padding:20px 22px}
|
| 553 |
+
.card-label{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#3a5060;margin-bottom:10px}
|
| 554 |
+
.url-box{font-family:'SF Mono','Fira Code',monospace;font-size:.8rem;color:#fff;background:rgba(0,0,0,.25);border:1px solid rgba(0,198,255,.12);border-radius:10px;padding:11px 14px;word-break:break-all;line-height:1.5}
|
| 555 |
+
.actions{display:flex;gap:10px;margin-top:14px}
|
| 556 |
+
.btn{padding:10px 22px;border-radius:10px;font-size:.84rem;font-weight:600;text-decoration:none;border:none;cursor:pointer;transition:all .15s;font-family:inherit}
|
| 557 |
+
.btn-primary{background:#00c6ff;color:#050810}.btn-primary:hover{background:#00b0e8}
|
| 558 |
+
.btn-ghost{background:rgba(255,255,255,.05);color:#7a8fa3;border:1px solid rgba(255,255,255,.09)}.btn-ghost:hover{background:rgba(255,255,255,.09);color:#aabdcc}
|
| 559 |
+
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
|
| 560 |
+
.stat{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.06);border-radius:12px;padding:16px 12px;text-align:center}
|
| 561 |
+
.stat-val{font-size:1.5rem;font-weight:800;color:#fff;line-height:1;margin-bottom:5px}
|
| 562 |
+
.stat-lbl{font-size:.68rem;color:#3a5060;font-weight:600;text-transform:uppercase;letter-spacing:.5px}</style></head>
|
| 563 |
+
<body><div class="wrap">
|
| 564 |
+
<div><div class="logo"><span class="van">Van</span> <span class="subs">Subs+</span></div>
|
| 565 |
+
<div class="badge-row" style="margin-top:12px"><span class="badge">π‘ Anti-AI</span><span class="badge green">β Human-Verified</span><span class="badge">π‘ SubF2M</span></div>
|
| 566 |
+
<p class="desc" style="margin-top:12px">Human-Verified Arabic & English Subtitles. Anti-AI.<br>IMDb-native search Β· streaming platform priority Β· Arabic first.</p></div>
|
| 567 |
+
<div class="card"><div class="card-label">Stremio Install URL</div>
|
| 568 |
+
<div class="url-box">${manifestUrl}</div>
|
| 569 |
+
<div class="actions">
|
| 570 |
+
<a href="/manifest.json" class="btn btn-primary">View Manifest</a>
|
| 571 |
+
<button class="btn btn-ghost" onclick="navigator.clipboard.writeText('${manifestUrl}').then(()=>{this.textContent='β Copied';setTimeout(()=>this.textContent='Copy URL',2000)})">Copy URL</button>
|
| 572 |
+
</div></div>
|
| 573 |
+
<div class="stats">
|
| 574 |
+
<div class="stat"><div class="stat-val">2</div><div class="stat-lbl">Languages</div></div>
|
| 575 |
+
<div class="stat"><div class="stat-val">4</div><div class="stat-lbl">Max per lang</div></div>
|
| 576 |
+
<div class="stat"><div class="stat-val">0</div><div class="stat-lbl">AI subs</div></div>
|
| 577 |
+
</div></div></body></html>`);
|
| 578 |
});
|
| 579 |
|
| 580 |
+
app.listen(PORT, '0.0.0.0', () => console.log(`β
VanStreams+ SubF2M v2.0.0 β http://0.0.0.0:${PORT}`));
|