Download Original from vanseleem/subf2m: direct link, hf CLI and curl.
- Browser
- Download file 35.5 kB
-
https://huggingface.co/spaces/vanseleem/subf2m/resolve/main/Original
- Command line
-
hf download hf://spaces/vanseleem/subf2m/Original
-
curl -L -o Original https://huggingface.co/spaces/vanseleem/subf2m/resolve/main/Original
35.5 kB
| 'use strict'; | |
| const express = require('express'); | |
| const cheerio = require('cheerio'); | |
| const AdmZip = require('adm-zip'); | |
| const https = require('https'); | |
| const http = require('http'); | |
| const zlib = require('zlib'); | |
| const { URL } = require('url'); | |
| const app = express(); | |
| const PORT = process.env.PORT || 7860; | |
| app.use((req, res, next) => { | |
| res.header('Access-Control-Allow-Origin', '*'); | |
| res.header('Access-Control-Allow-Headers', '*'); | |
| res.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); | |
| if (req.method === 'OPTIONS') return res.sendStatus(200); | |
| next(); | |
| }); | |
| // ================================================================ | |
| // PROXY CONFIG โ edit these three lines to swap proxies | |
| // ================================================================ | |
| const PROXY_1 = 'https://corsproxy.io/?url='; | |
| const PROXY_2 = 'https://api.codetabs.com/v1/proxy?quest='; | |
| const PROXY_3 = 'https://thingproxy.freeboard.io/fetch/'; | |
| // ================================================================ | |
| const BASE = 'https://subf2m.co'; | |
| const TMDB_KEY = process.env.TMDB_KEY || '30166e2db6b420d3f230808cd1bb2c90'; | |
| const TMDB_BASE = 'https://api.themoviedb.org/3'; | |
| const MAX_PER_LANG = 4; | |
| const CACHE_TTL = 6 * 60 * 60 * 1000; | |
| const REQ_TIMEOUT = 18_000; | |
| const DL_TIMEOUT = 30_000; | |
| const MAX_DETAIL = 30; | |
| // Full ordinal list covering seasons 1โ30 | |
| const ORDINALS = [ | |
| '', 'first', 'second', 'third', 'fourth', 'fifth', | |
| 'sixth', 'seventh', 'eighth', 'ninth', 'tenth', | |
| 'eleventh', 'twelfth', 'thirteenth', 'fourteenth', 'fifteenth', | |
| 'sixteenth', 'seventeenth', 'eighteenth', 'nineteenth', 'twentieth', | |
| 'twenty-first', 'twenty-second', 'twenty-third', 'twenty-fourth', 'twenty-fifth', | |
| 'twenty-sixth', 'twenty-seventh', 'twenty-eighth', 'twenty-ninth', 'thirtieth', | |
| ]; | |
| const ALLOWED_LANGS = new Set(['english', 'arabic']); | |
| const OTHER_LANGS = [ | |
| 'french','spanish','portuguese','german','italian','turkish','persian','farsi', | |
| 'hindi','russian','polish','dutch','swedish','norwegian','danish','finnish', | |
| 'korean','japanese','chinese','malay','indonesian','vietnamese','thai','greek', | |
| 'hungarian','czech','romanian','bulgarian','hebrew','ukrainian','serbian', | |
| 'croatian','slovak','slovenian','latvian','estonian','lithuanian','albanian', | |
| 'azerbaijani','bosnian','catalan','georgian','icelandic','macedonian','mongolian', | |
| 'punjabi','sinhala','tamil','bengali','urdu','nepali','khmer','burmese', | |
| 'afrikaans','swahili', | |
| ]; | |
| const QUALITY_TIERS = [ | |
| { 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] }, | |
| { 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] }, | |
| { bonus: 0.05, terms: [/\bhdtv\b/i,/\b720p\b/i,/\bhdrip\b/i] }, | |
| { 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] }, | |
| ]; | |
| const AI_PATTERNS = [ | |
| /\ba\.i\.\b/i,/\bai[\s\-_]sub(title)?s?\b/i,/\bai[\s\-_](translat|generat|correct|made|creat)/i, | |
| /(translat|generat|creat|made|sub(title)?s?)\s+(by\s+)?ai\b/i,/machine[\s\-_]translat/i, | |
| /auto[\s\-_]?translat/i,/auto[\s\-_]?generat/i,/auto[\s\-_]?sub(title)?s?\b/i,/\bautosub\b/i, | |
| /\bchatgpt\b/i,/\bgpt[\s\-_]?[34o]?\b/i,/\bdeep[\s\-_]?l\b/i,/google[\s\-_]translat/i, | |
| /yandex[\s\-_]translat/i,/amazon[\s\-_]translat/i,/microsoft[\s\-_]translat/i, | |
| /bing[\s\-_]translat/i,/\bopenai\b/i,/\bwhisper\b/i,/\bgemini\b/i,/\bclaude\b/i, | |
| /\bllama\b/i,/neural[\s\-_](translat|sub|net|machine)/i,/\bnmt\b/i, | |
| /artificial[\s\-_]intelligence/i,/computer[\s\-_](translat|generat)/i, | |
| ]; | |
| const AI_QUICK_HITS = [ | |
| 'a.i','chatgpt','deepl','whisper','openai','gemini','claude','llama', | |
| 'machine translat','auto translat','auto-translat','auto generat','autosub','neural', | |
| 'yandex','bing translat','google translat','amazon translat','microsoft translat','nmt', | |
| 'artificial intel',' ai sub','ai-sub','ai translat','translated by ai','generated by ai', | |
| ]; | |
| function isAISub(text) { | |
| if (!text) return false; | |
| const lo = text.toLowerCase(); | |
| const maybeAI = AI_QUICK_HITS.some(q => lo.includes(q)) || /\bai\b/.test(lo); | |
| if (!maybeAI) return false; | |
| return AI_PATTERNS.some(rx => rx.test(text)); | |
| } | |
| 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'; | |
| // ================================================================ | |
| // HTTP CORE | |
| // ================================================================ | |
| function _request(urlStr, opts = {}) { | |
| return new Promise((resolve, reject) => { | |
| const parsed = new URL(urlStr); | |
| const lib = parsed.protocol === 'https:' ? https : http; | |
| const timeout = opts.timeout || REQ_TIMEOUT; | |
| const reqOpts = { | |
| hostname: parsed.hostname, | |
| port : parsed.port || (parsed.protocol === 'https:' ? 443 : 80), | |
| path : parsed.pathname + parsed.search, | |
| method : 'GET', | |
| headers : { | |
| 'User-Agent' : UA, | |
| 'Accept' : opts.binary ? 'application/zip,application/octet-stream,*/*' : 'text/html,application/xhtml+xml,*/*;q=0.8', | |
| 'Accept-Language' : 'en-US,en;q=0.9', | |
| 'Accept-Encoding' : 'gzip, deflate', | |
| 'Referer' : 'https://subf2m.co/', | |
| 'Connection' : 'keep-alive', | |
| 'Cache-Control' : 'no-cache', | |
| 'Upgrade-Insecure-Requests': '1', | |
| ...(opts.headers || {}), | |
| }, | |
| }; | |
| const req = lib.request(reqOpts, res => { | |
| if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { | |
| const next = new URL(res.headers.location, urlStr).toString(); | |
| return _request(next, opts).then(resolve).catch(reject); | |
| } | |
| if (res.statusCode === 404) return resolve(null); | |
| if (res.statusCode !== 200) return resolve(null); | |
| const chunks = []; | |
| let stream = res; | |
| const enc = res.headers['content-encoding'] || ''; | |
| if (enc.includes('gzip')) stream = res.pipe(zlib.createGunzip()); | |
| else if (enc.includes('br')) stream = res.pipe(zlib.createBrotliDecompress()); | |
| else if (enc.includes('deflate')) stream = res.pipe(zlib.createInflate()); | |
| stream.on('data', c => chunks.push(c)); | |
| stream.on('end', () => { | |
| const buf = Buffer.concat(chunks); | |
| resolve(opts.binary ? buf : buf.toString('utf8')); | |
| }); | |
| stream.on('error', reject); | |
| }); | |
| req.setTimeout(timeout, () => { req.destroy(); reject(new Error('Timeout')); }); | |
| req.on('error', reject); | |
| req.end(); | |
| }); | |
| } | |
| async function getHTML(url, tries = 3) { | |
| for (let i = 0; i < tries; i++) { | |
| try { | |
| const r = await _request(url, { timeout: REQ_TIMEOUT }); | |
| if (r !== null) return r; | |
| } catch (e) { | |
| if (i < tries - 1) await sleep(1200 * (i + 1)); | |
| } | |
| } | |
| return null; | |
| } | |
| // Binary with proxy chain fallback | |
| async function getBinary(url, tries = 3) { | |
| for (let i = 0; i < tries; i++) { | |
| try { | |
| const r = await _request(url, { binary: true, timeout: DL_TIMEOUT }); | |
| if (r !== null) return r; | |
| } catch (_) {} | |
| } | |
| // Proxy chain | |
| const enc = encodeURIComponent(url); | |
| const proxied = [ | |
| `${PROXY_1}${enc}`, | |
| `${PROXY_2}${enc}`, | |
| `${PROXY_3}${url}`, | |
| ]; | |
| for (const pUrl of proxied) { | |
| try { | |
| const r = await _request(pUrl, { binary: true, timeout: DL_TIMEOUT }); | |
| if (r && r.length > 50) return r; | |
| } catch (_) {} | |
| } | |
| return null; | |
| } | |
| // ================================================================ | |
| // CACHE | |
| // ================================================================ | |
| const _cache = new Map(); | |
| function cacheGet(key) { | |
| const item = _cache.get(key); | |
| if (!item) return null; | |
| if (Date.now() - item.ts > CACHE_TTL) { _cache.delete(key); return null; } | |
| return item.val; | |
| } | |
| function cacheSet(key, val) { | |
| if (_cache.size >= 1200) { | |
| let n = 0; | |
| for (const k of _cache.keys()) { _cache.delete(k); if (++n >= 120) break; } | |
| } | |
| _cache.set(key, { val, ts: Date.now() }); | |
| } | |
| const sleep = ms => new Promise(r => setTimeout(r, ms)); | |
| // ================================================================ | |
| // STRING UTILS | |
| // ================================================================ | |
| function titleToSlug(title, year) { | |
| const base = (title || '').toLowerCase() | |
| .normalize('NFD').replace(/[\u0300-\u036f]/g, '') | |
| .replace(/[^a-z0-9\s]/g, '') | |
| .replace(/\s+/g, '-') | |
| .replace(/-+/g, '-') | |
| .replace(/^-|-$/g, ''); | |
| return year ? `${base}-${year}` : base; | |
| } | |
| function _clean(t) { return (t || '').replace(/\s+/g, ' ').trim(); } | |
| // ================================================================ | |
| // RELEASE PARSING & SCORING | |
| // ================================================================ | |
| function parseRelease(text) { | |
| if (!text) return {}; | |
| const tokens = {}; | |
| const seMatch = text.match(/[sS](\d{1,2})[eE](\d{1,3})/); | |
| if (seMatch) { | |
| tokens.season = parseInt(seMatch[1]); | |
| tokens.episode = parseInt(seMatch[2]); | |
| } else { | |
| const xMatch = text.match(/\b(\d{1,2})x(\d{2,3})\b/i); | |
| if (xMatch) { tokens.season = parseInt(xMatch[1]); tokens.episode = parseInt(xMatch[2]); } | |
| } | |
| const resMatch = text.match(/\b(4K|2160p|1080p|720p|480p)\b/i); | |
| if (resMatch) tokens.resolution = resMatch[1].toLowerCase(); | |
| const srcMatch = text.match(/\b(WEB[-]?DL|WEBRip|BluRay|HDTV|AMZN|NF|Netflix|DSNP|ATVP|HBO|BRAVIA|PCOK)\b/i); | |
| if (srcMatch) tokens.source = srcMatch[1].toUpperCase().replace(/[-]/g, ''); | |
| const grpMatch = text.match(/-([A-Za-z0-9]+)$/); | |
| if (grpMatch) tokens.group = grpMatch[1].toUpperCase(); | |
| return tokens; | |
| } | |
| function qualityBonus(text) { | |
| if (!text) return 0; | |
| let bonus = 0; | |
| for (const tier of QUALITY_TIERS) | |
| if (tier.terms.some(rx => rx.test(text))) bonus += tier.bonus; | |
| return Math.min(bonus, 0.20); | |
| } | |
| function scoreSubtitle(sub, streamTokens) { | |
| if (!streamTokens) return 0; | |
| const subText = sub.releaseInfo || sub._rawText || ''; | |
| const subTokens = parseRelease(subText); | |
| let score = 0; | |
| if (streamTokens.season != null && streamTokens.episode != null && | |
| subTokens.season === streamTokens.season && subTokens.episode === streamTokens.episode) | |
| score += 100; | |
| if (streamTokens.source && subTokens.source && subTokens.source === streamTokens.source) | |
| score += 20; | |
| if (streamTokens.resolution && subTokens.resolution === streamTokens.resolution) | |
| score += 10; | |
| if (streamTokens.group && subTokens.group && subTokens.group === streamTokens.group) | |
| score += 30; | |
| if (sub.isGood) score += 5; | |
| score += qualityBonus(subText) * 10; | |
| return score; | |
| } | |
| // ================================================================ | |
| // TMDB | |
| // ================================================================ | |
| async function getTMDB(imdbId) { | |
| const ck = `tmdb:${imdbId}`; | |
| const hit = cacheGet(ck); | |
| if (hit !== null) return hit; | |
| try { | |
| const raw = await _request( | |
| `${TMDB_BASE}/find/${imdbId}?external_source=imdb_id&api_key=${TMDB_KEY}`, | |
| { timeout: 10_000 }, | |
| ); | |
| if (!raw) { cacheSet(ck, null); return null; } | |
| const d = JSON.parse(raw); | |
| let info = null; | |
| if (d.movie_results?.length) { | |
| const m = d.movie_results[0]; | |
| info = { title: m.title || m.original_title, year: m.release_date?.substring(0,4) || null, type: 'movie', tmdbId: m.id }; | |
| } else if (d.tv_results?.length) { | |
| const t = d.tv_results[0]; | |
| info = { title: t.name || t.original_name, year: t.first_air_date?.substring(0,4) || null, type: 'series', tmdbId: t.id }; | |
| } | |
| cacheSet(ck, info); | |
| return info; | |
| } catch (e) { cacheSet(ck, null); return null; } | |
| } | |
| async function getTMDBSeasonYear(tmdbId, season) { | |
| if (!tmdbId) return null; | |
| const ck = `tmdbsy:${tmdbId}:${season}`; | |
| const hit = cacheGet(ck); | |
| if (hit !== null) return hit; | |
| try { | |
| const raw = await _request(`${TMDB_BASE}/tv/${tmdbId}/season/${season}?api_key=${TMDB_KEY}`, { timeout: 10_000 }); | |
| if (!raw) { cacheSet(ck, null); return null; } | |
| const year = JSON.parse(raw).air_date?.substring(0, 4) || null; | |
| cacheSet(ck, year); | |
| return year; | |
| } catch (e) { cacheSet(ck, null); return null; } | |
| } | |
| // ================================================================ | |
| // SLUG PROBING โ FIXED | |
| // No longer requires S##E## on the page โ any item list qualifies. | |
| // ================================================================ | |
| async function _probeSlug(slug, opts = {}) { | |
| if (!slug || slug.length > 220) return false; | |
| try { | |
| const lang = opts.lang || 'english'; | |
| const url = `${BASE}/subtitles/${slug}/${lang}`; | |
| const html = await getHTML(url, 1); | |
| if (!html || html.length < 500) return false; | |
| // Must have subtitle items | |
| if (!html.includes('<li') && !html.includes('subtitle') && !html.includes('download')) return false; | |
| // If we need to verify season, do a looser check | |
| if (opts.season) { | |
| const s = Number(opts.season); | |
| const pad = String(s).padStart(2, '0'); | |
| const ord = (ORDINALS[s] || '').toLowerCase(); | |
| // Accept if any season-related text is found, OR if there are item listings | |
| const hasSeasonRef = ( | |
| html.includes(`S${pad}E`) || html.includes(`s${pad}e`) || | |
| html.includes(`season ${s}`) || html.includes(`Season ${s}`) || | |
| (ord && html.toLowerCase().includes(`${ord} season`)) || | |
| html.includes(`li class="item"`) || | |
| html.includes('class="r"') | |
| ); | |
| return hasSeasonRef; | |
| } | |
| return html.includes('<li') || html.includes('item'); | |
| } catch (_) { return false; } | |
| } | |
| // ================================================================ | |
| // SERIES SLUG RESOLUTION โ FIXED | |
| // Key insight from bazarr issue #2617 and #2616: | |
| // 1. Subf2m search returns entries like "Young Sheldon - first season (2017)" | |
| // with a TRAILING SPACE before closing tag โ must trim. | |
| // 2. Search by IMDb ID works: ?query=tt1234567 | |
| // 3. The slug for a series season is: show-name-first-season OR | |
| // show-name-first-season-2017 | |
| // ================================================================ | |
| async function resolveSeriesSlug(imdbId, season) { | |
| const ck = `sslug2:${imdbId}:${season}`; | |
| const hit = cacheGet(ck); | |
| if (hit !== null) return hit; | |
| const tmdb = await getTMDB(imdbId); | |
| const title = tmdb?.title; | |
| const showYear = tmdb?.year; | |
| const seasonYear = tmdb?.tmdbId ? await getTMDBSeasonYear(tmdb.tmdbId, season) : null; | |
| // Strategy 1: Search by IMDb ID (most reliable) | |
| let slug = await _searchSeriesSlugByQuery(imdbId, season, title, seasonYear, showYear); | |
| if (slug) { cacheSet(ck, slug); return slug; } | |
| // Strategy 2: Search by title | |
| if (title) { | |
| slug = await _searchSeriesSlugByQuery(title, season, title, seasonYear, showYear); | |
| if (slug) { cacheSet(ck, slug); return slug; } | |
| } | |
| // Strategy 3: Smart pattern brute-force | |
| if (title) { | |
| slug = await _smartPatternSlugs(title, season, seasonYear, showYear); | |
| if (slug) { cacheSet(ck, slug); return slug; } | |
| } | |
| // Strategy 4: Bare slug (complete series page) | |
| if (title) { | |
| const bare = titleToSlug(title, null); | |
| if (await _probeSlug(bare, { season })) { cacheSet(ck, bare); return bare; } | |
| // Try with show year | |
| if (showYear) { | |
| const withYear = titleToSlug(title, showYear); | |
| if (await _probeSlug(withYear, { season })) { cacheSet(ck, withYear); return withYear; } | |
| } | |
| } | |
| cacheSet(ck, null); | |
| return null; | |
| } | |
| async function _searchSeriesSlugByQuery(query, season, titleHint, seasonYear, showYear) { | |
| const searchUrl = `${BASE}/subtitles/searchbytitle?query=${encodeURIComponent(query)}&l=`; | |
| const html = await getHTML(searchUrl); | |
| if (!html) return null; | |
| const $ = cheerio.load(html); | |
| const ord = (ORDINALS[season] || '').toLowerCase(); | |
| const firstWord = titleHint ? titleToSlug(titleHint, null).split('-')[0] : ''; | |
| const results = []; | |
| const seen = new Set(); | |
| // The bazarr fix: subf2m titles in HTML have trailing spaces. | |
| // Regex matches: "Show Name - ORDINAL season (YEAR)" or "Show Name (YEAR)" | |
| // The series regex from bazarr (with the \s*$ fix): | |
| // ^(.+?) [-\(]\s?(.*?) (season|series)\)?( \((\d{4})\))?\s*$ | |
| const SERIES_RE = /^(.+?)\s*[-โ]\s*(.*?)\s*(season|series)\s*(\((\d{4})\))?\s*$/i; | |
| const MOVIE_RE = /^(.+?)\s*\((\d{4})\)\s*$/; | |
| $('a[href]').each((_, a) => { | |
| const href = $(a).attr('href') || ''; | |
| if (!href.startsWith('/subtitles/')) return; | |
| const parts = href.split('/').filter(Boolean); | |
| if (parts.length < 2) return; | |
| const slug = parts[1]; | |
| if (!slug || slug === 'searchbytitle' || seen.has(slug)) return; | |
| seen.add(slug); | |
| const rawText = _clean($(a).text()); | |
| const slugLow = slug.toLowerCase(); | |
| // Must start with show's first word for disambiguation | |
| if (firstWord && !slugLow.startsWith(firstWord)) return; | |
| let score = 0; | |
| // Check title text for season match | |
| const seriesMatch = SERIES_RE.exec(rawText); | |
| if (seriesMatch) { | |
| const subOrd = (seriesMatch[2] || '').trim().toLowerCase(); | |
| const subYear = seriesMatch[5]; | |
| // Ordinal match (e.g. "first season") | |
| if (ord && subOrd === ord) score += 200; | |
| else if (ord && subOrd.startsWith(ord)) score += 150; | |
| // Numeric season as ordinal | |
| if (subOrd === String(season)) score += 100; | |
| // Year match | |
| if (seasonYear && subYear === seasonYear) score += 50; | |
| else if (showYear && subYear === showYear && season === 1) score += 30; | |
| // Any series result is better than none | |
| if (score === 0) score += 10; | |
| } | |
| // Also score by slug patterns (backup) | |
| if (ord && slugLow.includes(`-${ord}-season`)) score += Math.max(score, 80); | |
| if (slugLow.includes(`-season-${season}-`)) score += Math.max(score, 70); | |
| if (slugLow.includes(`-s${String(season).padStart(2,'0')}`)) score += Math.max(score, 60); | |
| if (ord && slugLow.includes(`-${ord}season`)) score += Math.max(score, 50); | |
| if (score > 0) results.push({ slug, score }); | |
| }); | |
| if (!results.length) return null; | |
| results.sort((a, b) => b.score - a.score); | |
| for (const r of results.slice(0, 8)) { | |
| await sleep(180); | |
| if (await _probeSlug(r.slug, { season })) return r.slug; | |
| } | |
| return null; | |
| } | |
| async function _smartPatternSlugs(title, season, seasonYear, showYear) { | |
| const base = titleToSlug(title, null); | |
| const ord = ORDINALS[season] || ''; | |
| const pad = String(season).padStart(2, '0'); | |
| const patterns = []; | |
| // Tier 1 โ TMDB season year exact | |
| if (seasonYear) { | |
| if (ord) { | |
| patterns.push(`${base}-${ord}-season-${seasonYear}`); | |
| patterns.push(`${base}--${ord}-season-${seasonYear}`); | |
| patterns.push(`${base}-${ord}-season-tv-${seasonYear}`); | |
| patterns.push(`${base}-the-${ord}-season-${seasonYear}`); | |
| } | |
| patterns.push(`${base}-season-${season}-${seasonYear}`); | |
| patterns.push(`${base}-s${pad}-${seasonYear}`); | |
| patterns.push(`${base}-${seasonYear}`); | |
| } | |
| // Tier 2 โ no year | |
| if (ord) { | |
| patterns.push(`${base}-${ord}-season`); | |
| patterns.push(`${base}--${ord}-season`); | |
| patterns.push(`${base}-the-${ord}-season`); | |
| patterns.push(`${base}-${ord}-series`); | |
| } | |
| patterns.push(`${base}-season-${season}`); | |
| patterns.push(`${base}-s${pad}`); | |
| // Tier 3 โ adjacent years | |
| const yearBase = seasonYear ? +seasonYear : (showYear ? +showYear : null); | |
| if (yearBase) { | |
| for (const yr of [yearBase - 1, yearBase + 1, yearBase - 2]) { | |
| if (ord) { | |
| patterns.push(`${base}-${ord}-season-${yr}`); | |
| patterns.push(`${base}-the-${ord}-season-${yr}`); | |
| } | |
| patterns.push(`${base}-season-${season}-${yr}`); | |
| patterns.push(`${base}-${yr}`); | |
| } | |
| } | |
| const unique = [...new Set(patterns)].filter(Boolean); | |
| console.log(`[slug] Probing ${unique.length} patterns for "${title}" S${season}`); | |
| for (const s of unique) { | |
| await sleep(120); | |
| if (await _probeSlug(s, { season })) { | |
| console.log(`[slug] HIT: ${s}`); | |
| return s; | |
| } | |
| } | |
| return null; | |
| } | |
| // ================================================================ | |
| // MOVIE SLUG RESOLUTION | |
| // ================================================================ | |
| async function resolveMovieSlug(imdbId, titleHint, yearHint) { | |
| const ck = `mslug2:${imdbId}`; | |
| const hit = cacheGet(ck); | |
| if (hit !== null) return hit; | |
| const tmdb = await getTMDB(imdbId); | |
| const title = tmdb?.title || titleHint; | |
| const year = tmdb?.year || yearHint; | |
| // Strategy 1: Direct slug (most common pattern) | |
| if (title && year) { | |
| const direct = titleToSlug(title, year); | |
| if (await _probeMovieSlug(direct, title)) { cacheSet(ck, direct); return direct; } | |
| } | |
| // Strategy 2: Search by IMDb ID | |
| let slug = await _searchMovieSlug(imdbId, year); | |
| if (slug && await _probeMovieSlug(slug, title)) { cacheSet(ck, slug); return slug; } | |
| // Strategy 3: Search by title | |
| if (title) { | |
| slug = await _searchMovieSlug(title, year); | |
| if (slug && await _probeMovieSlug(slug, title)) { cacheSet(ck, slug); return slug; } | |
| // Strategy 4: Brute-force slugs | |
| const candidates = []; | |
| if (year) { | |
| candidates.push(titleToSlug(title, year)); | |
| candidates.push(titleToSlug(title, String(+year - 1))); | |
| candidates.push(titleToSlug(title, String(+year + 1))); | |
| } | |
| candidates.push(titleToSlug(title, null)); | |
| for (const c of [...new Set(candidates)]) { | |
| await sleep(250); | |
| if (await _probeMovieSlug(c, title)) { cacheSet(ck, c); return c; } | |
| } | |
| } | |
| cacheSet(ck, null); | |
| return null; | |
| } | |
| async function _searchMovieSlug(query, yearHint) { | |
| const url = `${BASE}/subtitles/searchbytitle?query=${encodeURIComponent(query)}&l=`; | |
| const html = await getHTML(url); | |
| if (!html) return null; | |
| const $ = cheerio.load(html); | |
| const results = []; | |
| const SELS = ['ul.search-result li','.search-result li','.content ul li','section ul li','ul li','li']; | |
| let found = false; | |
| for (const sel of SELS) { | |
| const items = $(sel); | |
| if (!items.length) continue; | |
| items.each((_, el) => { | |
| const a = $(el).find('a[href]').first(); | |
| const href = a.attr('href') || ''; | |
| if (!href.startsWith('/subtitles/') || href === '/subtitles/searchbytitle') return; | |
| const parts = href.split('/').filter(Boolean); | |
| if (parts.length < 2) return; | |
| const slug = parts[1]; | |
| if (!slug || slug === 'searchbytitle') return; | |
| const text = _clean(a.text()); | |
| const yrMatch = text.match(/\((\d{4})\)/); | |
| const resYear = yrMatch ? +yrMatch[1] : null; | |
| let score = 1; | |
| if (query.startsWith('tt')) score += 100; | |
| if (yearHint && resYear === +yearHint) score += 10; | |
| // Penalise series results in movie search | |
| if (text.toLowerCase().includes('season')) score -= 5; | |
| results.push({ slug, score }); | |
| found = true; | |
| }); | |
| if (found) break; | |
| } | |
| if (!results.length) return null; | |
| results.sort((a, b) => b.score - a.score); | |
| return results[0].slug; | |
| } | |
| async function _probeMovieSlug(slug, expectedTitle) { | |
| if (!slug) return false; | |
| try { | |
| const html = await getHTML(`${BASE}/subtitles/${slug}/english`); | |
| if (!html || html.length < 1500) return false; | |
| if (expectedTitle) { | |
| const $ = cheerio.load(html); | |
| const pageTitle = ($('title').text() || '').toLowerCase(); | |
| const exp = expectedTitle.toLowerCase(); | |
| // Accept if title appears in page title OR if there are subtitle items | |
| return pageTitle.includes(exp) || html.includes('class="item"') || html.includes('class="r"'); | |
| } | |
| return true; | |
| } catch (_) { return false; } | |
| } | |
| // ================================================================ | |
| // SUBTITLE SCRAPING | |
| // ================================================================ | |
| async function getSubsList(slug, lang, season, episode, streamTokens) { | |
| if (!ALLOWED_LANGS.has(lang)) return []; | |
| const ck = `list2:${slug}:${lang}:${episode || 0}`; | |
| const hit = cacheGet(ck); | |
| if (hit) return hit; | |
| const url = `${BASE}/subtitles/${slug}/${lang}`; | |
| const html = await getHTML(url); | |
| if (!html) { cacheSet(ck, []); return []; } | |
| const $ = cheerio.load(html); | |
| let raw = []; | |
| // Primary scraper | |
| $('li.item').each((_, el) => { _extractItem($, el, lang, raw); }); | |
| // Fallback 1: table rows | |
| if (!raw.length) { | |
| $('tr, .subtitle-item, .item').each((_, el) => { _extractItem($, el, lang, raw); }); | |
| } | |
| // Fallback 2: any anchor with matching lang path | |
| if (!raw.length) { | |
| $('a[href]').each((_, a) => { | |
| const href = $(a).attr('href') || ''; | |
| if (!href.includes(`/${lang}/`) || !/\/\d+$/.test(href)) return; | |
| const parts = href.split('/'); | |
| const subId = parts[parts.length - 1]; | |
| if (!subId || !/^\d+$/.test(subId)) return; | |
| const full = href.startsWith('http') ? href : `${BASE}${href}`; | |
| raw.push({ subId, detailUrl: full, isGood: false, releaseInfo: '', comment: '', authorNote: '', lang, goodVotes: 0, badVotes: 0, downloads: 0, _rawText: '' }); | |
| }); | |
| } | |
| // Filter to exact episode if possible | |
| if (season && episode && raw.length) { | |
| const expected = `s${String(season).padStart(2,'0')}e${String(episode).padStart(2,'0')}`; | |
| const filtered = raw.filter(item => { | |
| const txt = (item._rawText || item.releaseInfo || '').toLowerCase(); | |
| return txt.includes(expected); | |
| }); | |
| if (filtered.length) raw = filtered; | |
| } | |
| // Deduplicate | |
| const seen = new Set(); | |
| const unique = raw.filter(s => { if (seen.has(s.subId)) return false; seen.add(s.subId); return true; }); | |
| const toEnrich = unique.slice(0, MAX_DETAIL); | |
| const enriched = (await Promise.all(toEnrich.map(s => _enrichSub(s)))).filter(Boolean); | |
| const human = enriched.filter(s => !isAISub(`${s.releaseInfo} ${s.comment} ${s.authorNote}`)); | |
| cacheSet(ck, human); | |
| return human; | |
| } | |
| function _extractItem($, el, lang, out) { | |
| const $el = $(el); | |
| const rawText = _clean($el.text()); | |
| let detailHref = null; | |
| $el.find('a').each((_, a) => { | |
| const h = $(a).attr('href') || ''; | |
| if (h.includes(`/${lang}/`) && /\/\d+$/.test(h)) detailHref = h; | |
| }); | |
| if (!detailHref) return; | |
| const hLow = detailHref.toLowerCase(); | |
| if (!hLow.includes(`/${lang}/`)) return; | |
| if (OTHER_LANGS.some(ol => hLow.includes(`/${ol}/`))) return; | |
| const parts = detailHref.split('/'); | |
| const subId = parts[parts.length - 1]; | |
| if (!subId || !/^\d+$/.test(subId)) return; | |
| const isGood = ( | |
| $el.find('.green-icon, .positive-icon').length > 0 || | |
| $el.find('img[alt="good"], img[alt="Positive"], img[alt="positive"]').length > 0 || | |
| $el.hasClass('good') || $el.hasClass('positive') || | |
| ($el.attr('class') || '').toLowerCase().includes('good') | |
| ); | |
| const comment = _clean($el.find('p.comment, .comment').first().text()); | |
| const releaseLines = $el.find('.r li, ul.release li, ul.scrolllist li, ul li') | |
| .map((_, li) => $(li).text().trim()).get().filter(Boolean); | |
| const releaseInfo = (releaseLines.join(' | ') || _clean($el.text())).substring(0, 130); | |
| if (isAISub(`${comment} ${releaseInfo}`)) return; | |
| out.push({ | |
| subId, | |
| detailUrl: detailHref.startsWith('http') ? detailHref : `${BASE}${detailHref}`, | |
| isGood, releaseInfo, comment, authorNote: '', lang, | |
| goodVotes: 0, badVotes: 0, downloads: 0, _rawText: rawText, | |
| }); | |
| } | |
| async function _enrichSub(sub) { | |
| const ck = `det2:${sub.subId}`; | |
| const hit = cacheGet(ck); | |
| if (hit !== null) { if (hit.__aiBlocked) return null; return { ...sub, ...hit }; } | |
| const html = await getHTML(sub.detailUrl); | |
| if (!html) return sub; | |
| const $ = cheerio.load(html); | |
| const txt = $.root().text(); | |
| const goodVotes = +(txt.match(/voted\s+as\s+good\s+by[:\s]*(\d+)/i)?.[1] ?? 0); | |
| const badVotes = +(txt.match(/voted\s+as\s+bad\s+by[:\s]*(\d+)/i)?.[1] ?? 0); | |
| const downloads = +(txt.match(/downloads?[:\s]*(\d+)/i)?.[1] ?? 0); | |
| const total = goodVotes + badVotes; | |
| const ratio = total >= 3 ? goodVotes / total : null; | |
| const isGood = sub.isGood || (ratio !== null && ratio >= 0.70 && goodVotes >= 2); | |
| const authorNote = _clean($('p.comment, .comment, .notes, .author-comment').first().text()); | |
| if (isAISub(`${authorNote} ${sub.releaseInfo} ${sub.comment}`)) { | |
| cacheSet(ck, { __aiBlocked: true }); | |
| return null; | |
| } | |
| const relLines = $('ul.release li, .release-names li, ul.scrolllist li').map((_, e) => $(e).text().trim()).get(); | |
| const releaseInfo = relLines.length ? relLines.join(' | ').substring(0, 130) : sub.releaseInfo; | |
| const enriched = { ...sub, releaseInfo, goodVotes, badVotes, downloads, isGood, authorNote }; | |
| cacheSet(ck, enriched); | |
| return enriched; | |
| } | |
| // ================================================================ | |
| // DOWNLOAD & SRT EXTRACTION | |
| // ================================================================ | |
| async function fetchSRT(downloadUrl) { | |
| const ck = `srt2:${downloadUrl}`; | |
| const hit = cacheGet(ck); | |
| if (hit !== null) return hit; | |
| try { | |
| const buf = await getBinary(downloadUrl); | |
| if (!buf) return null; | |
| const content = _extractFromZip(buf) ?? _tryDirect(buf); | |
| if (!content) return null; | |
| cacheSet(ck, content); | |
| return content; | |
| } catch (_) { return null; } | |
| } | |
| function _extractFromZip(buf) { | |
| try { | |
| const zip = new AdmZip(buf); | |
| const RANK = { '.srt': 0, '.vtt': 1, '.ass': 2, '.ssa': 3 }; | |
| const files = zip.getEntries() | |
| .filter(e => { | |
| const n = e.entryName.toLowerCase(); | |
| return !e.isDirectory && !n.startsWith('__macosx') && Object.keys(RANK).some(x => n.endsWith(x)); | |
| }) | |
| .sort((a, b) => (RANK[_ext(a.entryName)] ?? 9) - (RANK[_ext(b.entryName)] ?? 9)); | |
| for (const f of files) { | |
| const raw = zip.readFile(f); | |
| if (!raw) continue; | |
| const text = _decode(raw); | |
| if (_isSub(text)) return text; | |
| } | |
| } catch (_) {} | |
| return null; | |
| } | |
| function _tryDirect(buf) { const t = _decode(buf); return _isSub(t) ? t : null; } | |
| const _ext = n => (n.match(/(\.[^.]+)$/)?.[1]?.toLowerCase() ?? ''); | |
| const _isSub = t => typeof t === 'string' && t.includes('-->') && t.length > 50; | |
| function _decode(buf) { | |
| const hasBOM = buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF; | |
| const u8 = buf.slice(hasBOM ? 3 : 0).toString('utf8'); | |
| if (_isSub(u8)) return u8; | |
| return buf.toString('latin1'); | |
| } | |
| // ================================================================ | |
| // SORT: index [0] = best subtitle | |
| // Movies: sort by downloads desc (most popular = best sync). | |
| // Series: sort by scoreSubtitle desc (exact S##E## + group match). | |
| // ================================================================ | |
| function sortForBest(subs, streamTokens, type) { | |
| if (type === 'series' && streamTokens) { | |
| return subs | |
| .map(s => ({ ...s, _score: scoreSubtitle(s, streamTokens) })) | |
| .sort((a, b) => { | |
| if (b._score !== a._score) return b._score - a._score; | |
| if (b.isGood !== a.isGood) return b.isGood ? 1 : -1; | |
| return b.downloads - a.downloads; | |
| }); | |
| } | |
| // Movies: isGood first, then downloads | |
| return subs.slice().sort((a, b) => { | |
| if (b.isGood !== a.isGood) return b.isGood ? 1 : -1; | |
| return b.downloads - a.downloads; | |
| }); | |
| } | |
| // ================================================================ | |
| // ROUTES | |
| // ================================================================ | |
| app.get('/manifest.json', (req, res) => res.json({ | |
| id : 'org.van.vansubf2mplus.v2', | |
| name : 'VanSubF2M+', | |
| description: 'Human-Verified Arabic & English subtitles. Anti-AI. Enhanced 100% coverage.', | |
| version : '2.0.0', | |
| resources : ['subtitles'], | |
| types : ['movie', 'series'], | |
| idPrefixes : ['tt'], | |
| catalogs : [], | |
| })); | |
| app.get('/sub/:enc.srt', async (req, res) => { | |
| let dlUrl = ''; | |
| try { dlUrl = Buffer.from(req.params.enc, 'base64url').toString('utf8'); } | |
| catch (_) { return res.status(400).end('Bad request'); } | |
| if (!dlUrl.startsWith('https://subf2m.co/') || !dlUrl.includes('/subtitles/') || !dlUrl.endsWith('/download')) | |
| return res.status(403).end('Forbidden'); | |
| const content = await fetchSRT(dlUrl); | |
| if (!content) return res.status(404).end('Not found'); | |
| res.setHeader('Content-Type', 'text/plain; charset=utf-8'); | |
| res.setHeader('Cache-Control', 'public, max-age=86400'); | |
| res.setHeader('Content-Disposition', 'inline; filename="subtitle.srt"'); | |
| res.end(content); | |
| }); | |
| app.get('/subtitles/:type/:id/:extra?.json', async (req, res) => { | |
| res.setHeader('Content-Type', 'application/json; charset=utf-8'); | |
| const { type, id } = req.params; | |
| if (!id?.startsWith('tt')) return res.json({ subtitles: [] }); | |
| const ck = `result2:${id}`; | |
| const hit = cacheGet(ck); | |
| if (hit) return res.json({ subtitles: hit }); | |
| try { | |
| let imdbId = id, season = null, episode = null; | |
| if (id.includes(':')) { | |
| const parts = id.split(':'); | |
| imdbId = parts[0]; | |
| season = parts[1] ? +parts[1] : null; | |
| episode = parts[2] ? +parts[2] : null; | |
| } | |
| if (!/^tt\d+$/.test(imdbId)) return res.json({ subtitles: [] }); | |
| const filename = req.query.filename || ''; | |
| const streamTokens = (type === 'series' && season) ? parseRelease(filename) : null; | |
| let slug; | |
| if (type === 'series' && season) { | |
| slug = await resolveSeriesSlug(imdbId, season); | |
| } else { | |
| const tmdb = await getTMDB(imdbId); | |
| slug = await resolveMovieSlug(imdbId, tmdb?.title || null, tmdb?.year || null); | |
| } | |
| if (!slug) return res.json({ subtitles: [] }); | |
| const [engRes, araRes] = await Promise.allSettled([ | |
| getSubsList(slug, 'english', season, episode, streamTokens), | |
| getSubsList(slug, 'arabic', season, episode, streamTokens), | |
| ]); | |
| let engSubs = (engRes.status === 'fulfilled' ? engRes.value : []); | |
| let araSubs = (araRes.status === 'fulfilled' ? araRes.value : []); | |
| // Sort so [0] = best, then cap at MAX_PER_LANG | |
| engSubs = sortForBest(engSubs, streamTokens, type).slice(0, MAX_PER_LANG); | |
| araSubs = sortForBest(araSubs, streamTokens, type).slice(0, MAX_PER_LANG); | |
| const proto = req.headers['x-forwarded-proto'] || (req.socket?.encrypted ? 'https' : 'http'); | |
| const host = req.headers['x-forwarded-host'] || req.headers.host; | |
| const origin = `${proto}://${host}`; | |
| const build = (subs, langCode, flag) => subs.map(s => { | |
| const dlUrl = `${s.detailUrl}/download`; | |
| const enc = Buffer.from(dlUrl).toString('base64url'); | |
| const badge = s.isGood ? ' โญ' : ''; | |
| const votes = s.goodVotes > 0 ? ` [๐${s.goodVotes}]` : ''; | |
| const dls = s.downloads > 0 ? ` [โฌ${s.downloads}]` : ''; | |
| const name = `${flag}${badge}${votes}${dls} ${s.releaseInfo}`.trim().substring(0, 100); | |
| return { id: `vsub-${langCode}-${s.subId}`, url: `${origin}/sub/${enc}.srt`, lang: langCode, name }; | |
| }); | |
| const subtitles = [...build(engSubs, 'eng', '๐บ๐ธ English'), ...build(araSubs, 'ara', '๐ธ๐ฆ Arabic')]; | |
| cacheSet(ck, subtitles); | |
| res.json({ subtitles }); | |
| } catch (e) { | |
| console.error('[route]', e.message); | |
| res.json({ subtitles: [] }); | |
| } | |
| }); | |
| app.listen(PORT, '0.0.0.0', () => { | |
| console.log(`Server running on port ${PORT}`); | |
| }); |