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