mirror of
https://gitlab.com/vyla-entertainment/stream-api.git
synced 2026-11-09 20:27:19 -05:00
1272 lines
51 KiB
JavaScript
1272 lines
51 KiB
JavaScript
import cluster from 'cluster'
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import dotenv from 'dotenv';
|
|
import http from 'http';
|
|
import { Readable } from 'stream';
|
|
import { CACHE_TTL } from './config.js';
|
|
import { handleSubtitleMovie, handleSubtitleTv } from './src/routes/subtitles.js';
|
|
import { handleDownloadMovie, handleDownloadTv } from './src/routes/downloads/main.js';
|
|
import { handleHealth } from './src/routes/health.js';
|
|
import { wrapUrl } from './src/utils/proxy.js';
|
|
import { handleTestRoute, handleDebugRoute } from './src/routes/test.js';
|
|
import { getUA, validateTmdbId } from './src/utils/helpers.js';
|
|
import VylaSDK from '@vyla-entertainment/sdk';
|
|
dotenv.config();
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const IS_HF = !!process.env.SPACE_ID;
|
|
const ENABLE_DEBUG_ROUTE = process.env.ENABLE_DEBUG_ROUTE === 'true';
|
|
const PORT = process.env.PORT || 7860;
|
|
const HF_FETCH_TIMEOUT = 12000;
|
|
const EARLY_CLOSE_MS = IS_HF ? 20000 : 14000;
|
|
const MAX_GLOBAL_TEST_CONCURRENCY = IS_HF ? 30 : 300;
|
|
const MAX_OUTBOUND_FETCH_CONCURRENCY = IS_HF ? 60 : 400;
|
|
const MAX_SHARED_INFLIGHT = 2000;
|
|
const MAX_IPC_PENDING = 2000;
|
|
|
|
const FALLBACK_BASE = '';
|
|
|
|
const LOGO_TEXT = (() => {
|
|
try { return fs.readFileSync(path.join(__dirname, 'public/assets/title.txt'), 'utf8'); } catch { return ''; }
|
|
})();
|
|
|
|
const GA_MEASUREMENT_ID = process.env.GA_MEASUREMENT_ID;
|
|
const GA_API_SECRET = process.env.GA_API_SECRET;
|
|
|
|
const M3U8_REGEX = /\.m3u8?(\?|$)|mpegurl|m3u8/i;
|
|
const TIKTOK_REGEX = /tiktokcdn\.com|ibyteimg\.com/i;
|
|
const STRIP_REGEX = /seg\.html|enproxy|letsgocdn\d+\.shop/i;
|
|
const STRIP_TEST_FAST = /seg\.html|enproxy|tiktokcdn|ibyteimg/i;
|
|
const URI_REPLACE = /URI="([^"]+)"/g;
|
|
|
|
const CORS_HEADERS = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS, PATCH, HEAD',
|
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, Origin, Accept, Access-Control-Request-Method, Access-Control-Request-Headers, X-Session-Token',
|
|
'Access-Control-Allow-Credentials': 'false',
|
|
'Access-Control-Max-Age': '86400',
|
|
};
|
|
|
|
const JSON_CORS = { 'Content-Type': 'application/json', ...CORS_HEADERS };
|
|
|
|
const ROUTE_PATTERNS = {
|
|
subtitleMovie: /^\/(?:api\/)?subtitles?\/movie\/([^/]+)$/,
|
|
subtitleTv: /^\/(?:api\/)?subtitles?\/tv\/([^/]+)\/([^/]+)\/([^/]+)$/,
|
|
debug: /^\/(?:api\/)?debug\/([^/]+)$/,
|
|
test: /^\/(?:api\/)?test\/([^/]+)$/,
|
|
downloadMovie: /^\/(?:api\/)?downloads?\/movie\/([^/]+)$/,
|
|
downloadTv: /^\/(?:api\/)?downloads?\/tv\/([^/]+)\/([^/]+)\/([^/]+)$/,
|
|
};
|
|
|
|
const TMDB_API_KEY = process.env.TMDB_API_KEY;
|
|
if (!TMDB_API_KEY) console.warn('Warning: TMDB_API_KEY not set, imdb-required sources will be unavailable');
|
|
|
|
// Internal VylaSDK instance
|
|
const sdk = new VylaSDK({
|
|
tmdbApiKey: TMDB_API_KEY
|
|
});
|
|
|
|
if (cluster.isPrimary) {
|
|
const cpus = (await import('os')).default.cpus().length;
|
|
const workerCount = IS_HF ? Math.min(cpus, 2) : (process.env.WORKER_COUNT ? Math.max(1, parseInt(process.env.WORKER_COUNT, 10)) : 1);
|
|
const sharedCache = new Map();
|
|
const SHARED_CACHE_MAX = 1500;
|
|
|
|
const pruneCache = () => {
|
|
const now = Date.now();
|
|
for (const [k, v] of sharedCache) {
|
|
if (now - v.ts > v.ttl) sharedCache.delete(k);
|
|
}
|
|
if (sharedCache.size > SHARED_CACHE_MAX) {
|
|
const overflow = sharedCache.size - SHARED_CACHE_MAX;
|
|
const it = sharedCache.keys();
|
|
for (let i = 0; i < overflow; i++) {
|
|
const k = it.next().value;
|
|
if (k === undefined) break;
|
|
sharedCache.delete(k);
|
|
}
|
|
}
|
|
};
|
|
|
|
const pruneTimer = setInterval(pruneCache, 30000);
|
|
pruneTimer.unref();
|
|
|
|
for (let i = 0; i < workerCount; i++) {
|
|
const w = cluster.fork({ WORKER_ID: String(i) });
|
|
w.on('online', () => w.send({ type: 'worker:id', id: i }));
|
|
}
|
|
|
|
cluster.on('message', (worker, msg) => {
|
|
if (!msg?.type) return;
|
|
|
|
if (msg.type === 'cache:get') {
|
|
const entry = sharedCache.get(msg.key);
|
|
const now = Date.now();
|
|
if (entry && now - entry.ts <= entry.ttl) {
|
|
worker.send({ type: 'cache:hit', id: msg.id, value: entry.value });
|
|
} else {
|
|
sharedCache.delete(msg.key);
|
|
worker.send({ type: 'cache:miss', id: msg.id });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'cache:set') {
|
|
sharedCache.set(msg.key, { value: msg.value, ts: Date.now(), ttl: msg.ttl || CACHE_TTL });
|
|
if (sharedCache.size > SHARED_CACHE_MAX) pruneCache();
|
|
for (const id in cluster.workers) {
|
|
if (cluster.workers[id] !== worker) {
|
|
try { cluster.workers[id]?.send({ type: 'cache:push', key: msg.key, value: msg.value, ttl: msg.ttl || CACHE_TTL }); } catch { }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const watchPaths = [
|
|
fileURLToPath(import.meta.url),
|
|
'./config.js',
|
|
'./src/routes/subtitles.js',
|
|
'./src/routes/downloads/main.js',
|
|
'./src/routes/health.js',
|
|
];
|
|
|
|
const intentionallyKilled = new Set();
|
|
let restarting = false;
|
|
const scheduleRestart = () => {
|
|
if (restarting) return;
|
|
restarting = true;
|
|
setTimeout(() => {
|
|
for (const id in cluster.workers) {
|
|
const w = cluster.workers[id];
|
|
if (!w) continue;
|
|
intentionallyKilled.add(w.process.pid);
|
|
w.kill();
|
|
}
|
|
restarting = false;
|
|
}, 500);
|
|
};
|
|
|
|
// fs.watch('./src/sources', { persistent: false }, scheduleRestart);
|
|
watchPaths.forEach(f => { try { fs.watch(f, scheduleRestart); } catch { } });
|
|
|
|
let pendingForks = 0;
|
|
let nextWorkerId = workerCount;
|
|
cluster.on('exit', (worker, code, signal) => {
|
|
const isIntentional = code === 0 || intentionallyKilled.delete(worker.process.pid);
|
|
const delay = isIntentional ? 0 : Math.min(++pendingForks * 1000, 5000);
|
|
setTimeout(() => {
|
|
pendingForks = Math.max(0, pendingForks - 1);
|
|
const id = nextWorkerId++;
|
|
const w = cluster.fork({ WORKER_ID: String(id) });
|
|
w.on('online', () => w.send({ type: 'worker:id', id }));
|
|
}, delay);
|
|
});
|
|
|
|
await new Promise(() => { });
|
|
}
|
|
|
|
const _nativeFetch = globalThis.fetch;
|
|
|
|
async function sendGAEvent(eventName, params = {}, clientId = null) {
|
|
if (!GA_MEASUREMENT_ID || !GA_API_SECRET) return;
|
|
const cid = clientId || `server.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
const payload = {
|
|
client_id: cid,
|
|
timestamp_micros: String(Date.now() * 1000),
|
|
non_personalized_ads: false,
|
|
events: [{
|
|
name: eventName,
|
|
params: {
|
|
engagement_time_msec: 100,
|
|
session_id: cid,
|
|
...params,
|
|
},
|
|
}],
|
|
};
|
|
try {
|
|
const res = await fetch(
|
|
`https://www.google-analytics.com/mp/collect?measurement_id=${GA_MEASUREMENT_ID}&api_secret=${GA_API_SECRET}`,
|
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
|
|
);
|
|
} catch (err) {
|
|
}
|
|
}
|
|
|
|
async function validateGAConfig() {
|
|
if (!GA_MEASUREMENT_ID || !GA_API_SECRET) {
|
|
return;
|
|
}
|
|
const payload = {
|
|
client_id: 'debug.startup',
|
|
events: [{ name: 'server_start', params: { engagement_time_msec: 100, session_id: 'debug.startup' } }],
|
|
};
|
|
try {
|
|
const validateRes = await fetch(
|
|
`https://www.google-analytics.com/debug/mp/collect?measurement_id=${GA_MEASUREMENT_ID}&api_secret=${GA_API_SECRET}`,
|
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
|
|
);
|
|
const body = await validateRes.json();
|
|
const issues = body?.validationMessages ?? [];
|
|
if (issues.length === 0) {
|
|
await sendGAEvent('server_start', { uptime: 0 }, 'debug.startup');
|
|
}
|
|
} catch (err) {
|
|
}
|
|
}
|
|
|
|
let outboundFetchActive = 0;
|
|
const outboundFetchQueue = [];
|
|
|
|
function acquireFetchSlot() {
|
|
if (outboundFetchActive < MAX_OUTBOUND_FETCH_CONCURRENCY) { outboundFetchActive++; return Promise.resolve(); }
|
|
return new Promise(resolve => outboundFetchQueue.push(resolve));
|
|
}
|
|
|
|
function releaseFetchSlot() {
|
|
const next = outboundFetchQueue.shift();
|
|
if (next) next();
|
|
else outboundFetchActive = Math.max(0, outboundFetchActive - 1);
|
|
}
|
|
|
|
globalThis.fetch = async (url, opts) => {
|
|
const signal = opts?.signal ?? AbortSignal.timeout(HF_FETCH_TIMEOUT);
|
|
await acquireFetchSlot();
|
|
try {
|
|
return await _nativeFetch(url, opts?.signal ? opts : { ...opts, signal });
|
|
} finally {
|
|
releaseFetchSlot();
|
|
}
|
|
};
|
|
|
|
class LRUCache {
|
|
#max; #ttl; #map;
|
|
|
|
constructor(max, ttl) {
|
|
this.#max = max;
|
|
this.#ttl = ttl;
|
|
this.#map = new Map();
|
|
}
|
|
|
|
get(key) {
|
|
const entry = this.#map.get(key);
|
|
if (!entry) return undefined;
|
|
if (Date.now() - entry.ts > this.#ttl) { this.#map.delete(key); return undefined; }
|
|
this.#map.delete(key);
|
|
this.#map.set(key, entry);
|
|
return entry.val;
|
|
}
|
|
|
|
set(key, val) {
|
|
if (this.#map.has(key)) this.#map.delete(key);
|
|
else if (this.#map.size >= this.#max) this.#map.delete(this.#map.keys().next().value);
|
|
this.#map.set(key, { val, ts: Date.now() });
|
|
}
|
|
|
|
has(key) {
|
|
const entry = this.#map.get(key);
|
|
if (!entry) return false;
|
|
if (Date.now() - entry.ts > this.#ttl) { this.#map.delete(key); return false; }
|
|
return true;
|
|
}
|
|
|
|
get size() { return this.#map.size; }
|
|
}
|
|
|
|
const mainCache = new LRUCache(600, CACHE_TTL);
|
|
const hlsVerifyCache = new LRUCache(400, 180_000);
|
|
const testResultCache = new LRUCache(400, 90_000);
|
|
|
|
const sharedInflight = new Map();
|
|
|
|
let ipcIdCounter = 0;
|
|
const ipcPending = new Map();
|
|
|
|
const ipcSend = (msg) => new Promise(resolve => {
|
|
if (ipcPending.size >= MAX_IPC_PENDING) { resolve(null); return; }
|
|
const id = ++ipcIdCounter;
|
|
ipcPending.set(id, resolve);
|
|
try {
|
|
process.send({ ...msg, id });
|
|
} catch {
|
|
ipcPending.delete(id);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
setTimeout(() => {
|
|
if (ipcPending.has(id)) { ipcPending.delete(id); resolve(null); }
|
|
}, 150);
|
|
});
|
|
|
|
process.on('message', (msg) => {
|
|
if (!msg) return;
|
|
if (msg.type === 'worker:id') return;
|
|
|
|
if (msg.type === 'cache:hit' || msg.type === 'cache:miss') {
|
|
const resolve = ipcPending.get(msg.id);
|
|
if (resolve) { ipcPending.delete(msg.id); resolve(msg); }
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'cache:push' && msg.key && msg.value !== undefined) {
|
|
mainCache.set(msg.key, msg.value);
|
|
testResultCache.set(msg.key, msg.value);
|
|
}
|
|
});
|
|
|
|
async function sharedCacheGet(key) {
|
|
const local = mainCache.get(key);
|
|
if (local !== undefined) return local;
|
|
if (!process.send) return undefined;
|
|
const reply = await ipcSend({ type: 'cache:get', key });
|
|
if (reply?.type === 'cache:hit') { mainCache.set(key, reply.value); return reply.value; }
|
|
return undefined;
|
|
}
|
|
|
|
function sharedCacheSet(key, value, ttl) {
|
|
mainCache.set(key, value);
|
|
testResultCache.set(key, value);
|
|
if (process.send) {
|
|
try { process.send({ type: 'cache:set', key, value, ttl: ttl || CACHE_TTL }); } catch { }
|
|
}
|
|
}
|
|
|
|
function getSharedCached(key, fn, ttl) {
|
|
const local = mainCache.get(key);
|
|
if (local !== undefined) return Promise.resolve(local);
|
|
|
|
const inflight = sharedInflight.get(key);
|
|
if (inflight) return inflight;
|
|
|
|
if (sharedInflight.size >= MAX_SHARED_INFLIGHT) {
|
|
return (async () => {
|
|
const shared = await sharedCacheGet(key);
|
|
if (shared !== undefined) return shared;
|
|
return fn();
|
|
})();
|
|
}
|
|
|
|
const p = withTimeout((async () => {
|
|
const shared = await sharedCacheGet(key);
|
|
if (shared !== undefined) return shared;
|
|
const val = await fn();
|
|
if (val != null) sharedCacheSet(key, val, ttl);
|
|
return val;
|
|
})(), 60_000).finally(() => sharedInflight.delete(key));
|
|
|
|
sharedInflight.set(key, p);
|
|
return p;
|
|
}
|
|
|
|
const safeDecode = s => { try { return decodeURIComponent(s); } catch { return s; } };
|
|
const jitter = ms => ms > 0 ? new Promise(r => setTimeout(r, Math.random() * ms)) : Promise.resolve();
|
|
const withTimeout = (promise, ms) => Promise.race([promise, new Promise(r => setTimeout(() => r(null), ms))]);
|
|
|
|
async function withRetry(fn, attempts = 2, delay = 300) {
|
|
for (let i = 0; i < attempts; i++) {
|
|
try {
|
|
const result = await fn();
|
|
if (result != null) return result;
|
|
} catch (err) {
|
|
if (i === attempts - 1) throw err;
|
|
await new Promise(r => setTimeout(r, delay + Math.random() * delay * 0.5));
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const ACTIVE_SOURCES = sdk.getSources(true);
|
|
const PROXY_PARAM_MAP = new Map(ACTIVE_SOURCES.map(cfg => [cfg.proxyParam, cfg]));
|
|
|
|
const BLOCKED_IPS = new Set([]);
|
|
|
|
let globalTestConcurrency = 0;
|
|
const testQueue = [];
|
|
|
|
function runTestQueue() {
|
|
while (testQueue.length > 0 && globalTestConcurrency < MAX_GLOBAL_TEST_CONCURRENCY) {
|
|
testQueue.shift().resolve();
|
|
globalTestConcurrency++;
|
|
}
|
|
}
|
|
|
|
async function acquireTestSlot() {
|
|
if (globalTestConcurrency < MAX_GLOBAL_TEST_CONCURRENCY) { globalTestConcurrency++; return; }
|
|
await new Promise(resolve => testQueue.push({ resolve }));
|
|
}
|
|
|
|
function releaseTestSlot() {
|
|
globalTestConcurrency = Math.max(0, globalTestConcurrency - 1);
|
|
runTestQueue();
|
|
}
|
|
|
|
const getAbsoluteBase = host =>
|
|
(host.startsWith('localhost') || host.startsWith('127.0.0.1')) ? `http://${host}` : `https://${host}`;
|
|
|
|
function buildM3u8Rewriter(rewriteSegments) {
|
|
return function rewrite(body, url, extraParam, absoluteBase) {
|
|
const safeBase = absoluteBase.replace(/^https:\/\/(localhost|127\.0\.0\.1)/, 'http://$1');
|
|
const qmark = url.indexOf('?');
|
|
const base = qmark === -1 ? url : url.slice(0, qmark);
|
|
const dir = base.slice(0, base.lastIndexOf('/') + 1);
|
|
const schemeEnd = url.indexOf('//') + 2;
|
|
const originBase = url.slice(0, url.indexOf('/', schemeEnd));
|
|
const prefix = `${safeBase}/api?url=`;
|
|
const lines = body.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const t = lines[i].trim();
|
|
if (!t) continue;
|
|
if (t.charCodeAt(0) === 35) {
|
|
let uIdx = t.indexOf('URI="');
|
|
if (uIdx !== -1) {
|
|
const end = t.indexOf('"', uIdx + 5);
|
|
if (end !== -1) {
|
|
const uri = t.slice(uIdx + 5, end);
|
|
const abs = uri.startsWith('http') ? uri : uri.startsWith('/') ? originBase + uri : dir + uri;
|
|
lines[i] = t.slice(0, uIdx + 5) + prefix + encodeURIComponent(abs) + extraParam + '"' + t.slice(end + 1);
|
|
}
|
|
}
|
|
} else {
|
|
const abs = t.startsWith('http') ? t : t.startsWith('/') ? originBase + t : dir + t;
|
|
lines[i] = rewriteSegments ? `${prefix}${encodeURIComponent(abs)}${extraParam}${STRIP_TEST_FAST.test(abs) ? '&tt=1' : ''}` : abs;
|
|
}
|
|
}
|
|
return lines.join('\n');
|
|
};
|
|
}
|
|
|
|
const rewriteM3u8 = buildM3u8Rewriter(true);
|
|
const rewriteM3u8KeyOnly = buildM3u8Rewriter(false);
|
|
|
|
async function fetchUpstream(url, extraHeaders = {}, timeoutMs = 30_000) {
|
|
let current = url.startsWith('http://') ? 'https://' + url.slice(7) : url;
|
|
const headers = { 'User-Agent': getUA(), ...extraHeaders };
|
|
const opts = { headers, redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) };
|
|
|
|
for (let i = 0; i <= 5; i++) {
|
|
const res = await _nativeFetch(current, opts);
|
|
|
|
if (res.status < 300 || res.status >= 400 || !res.headers.has('location')) {
|
|
return res;
|
|
}
|
|
res.body?.cancel();
|
|
const loc = res.headers.get('location');
|
|
current = loc.startsWith('http')
|
|
? (loc.startsWith('http://') ? 'https://' + loc.slice(7) : loc)
|
|
: new URL(loc, current).href;
|
|
}
|
|
|
|
throw new Error('redirect loop');
|
|
}
|
|
|
|
async function verifyStream(rawUrl, sourceKey, extraHeaders = {}) {
|
|
const cfg = sdk.getSources().find(c => c.key === sourceKey);
|
|
if (cfg?.skipVerify) return true;
|
|
|
|
const cacheKey = `vstream-${rawUrl}`;
|
|
const cached = hlsVerifyCache.get(cacheKey);
|
|
if (cached !== undefined) return cached;
|
|
|
|
try {
|
|
const res = await _nativeFetch(rawUrl, {
|
|
method: 'HEAD',
|
|
headers: { 'User-Agent': getUA(), ...(cfg?.verifyHeaders ?? {}), ...extraHeaders },
|
|
redirect: 'follow',
|
|
signal: AbortSignal.timeout(6_000),
|
|
});
|
|
res.body?.cancel();
|
|
const ok = res.status < 400;
|
|
hlsVerifyCache.set(cacheKey, ok);
|
|
return ok;
|
|
} catch {
|
|
hlsVerifyCache.set(cacheKey, false);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function verifyPlayable(proxiedUrl, extraHeaders = {}, skipProxyCheck = false) {
|
|
if (IS_HF && proxiedUrl.includes('.hf.space/api?url=')) {
|
|
try {
|
|
const parsed = new URL(proxiedUrl);
|
|
const rawUrl = decodeURIComponent(parsed.searchParams.get('url') || '');
|
|
const ph = parsed.searchParams.get('proxyHeaders');
|
|
if (ph) try { Object.assign(extraHeaders, JSON.parse(decodeURIComponent(ph))); } catch { }
|
|
if (rawUrl) { proxiedUrl = rawUrl; skipProxyCheck = true; }
|
|
} catch { }
|
|
}
|
|
const cached = hlsVerifyCache.get(proxiedUrl);
|
|
if (cached !== undefined) return cached;
|
|
const store = val => { hlsVerifyCache.set(proxiedUrl, val); return val; };
|
|
const fail = error => ({ ok: false, error });
|
|
try {
|
|
const fetchHeaders = extraHeaders['User-Agent'] ? extraHeaders : { 'User-Agent': getUA(), ...extraHeaders };
|
|
const m3u8Res = await _nativeFetch(proxiedUrl, { signal: AbortSignal.timeout(12000), headers: fetchHeaders });
|
|
if (!m3u8Res.ok) {
|
|
const val = fail(`m3u8 failed: ${m3u8Res.status}`);
|
|
if (m3u8Res.status !== 429) store(val);
|
|
return val;
|
|
}
|
|
const text = await m3u8Res.text();
|
|
if (/\.mpd(\?|$)/i.test(proxiedUrl) || text.includes('<MPD') || text.includes('urn:mpeg:dash')) {
|
|
return store({ ok: true, error: null });
|
|
}
|
|
if (!text.trim().startsWith('#EXTM3U')) return fail('invalid m3u8');
|
|
if (/^429$|^429\s/m.test(text) || text.includes('Too Many Requests')) return fail('Proxy Blocked or Invalid Hash');
|
|
if (!text.includes('#EXTINF') && !text.includes('#EXT-X-STREAM-INF')) return fail('empty playlist');
|
|
if (!skipProxyCheck) {
|
|
let nextUrl = null;
|
|
const lines = text.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const t = lines[i].trim();
|
|
if (t && t.charCodeAt(0) !== 35) { nextUrl = t; break; }
|
|
}
|
|
if (nextUrl) {
|
|
if (!nextUrl.startsWith('http')) nextUrl = new URL(nextUrl, proxiedUrl).href;
|
|
const variantRes = await _nativeFetch(nextUrl, { method: 'GET', headers: { ...fetchHeaders, 'Range': 'bytes=0-1024' }, signal: AbortSignal.timeout(10000) });
|
|
if (!variantRes.ok && variantRes.status !== 206) return fail(`Variant failed: ${variantRes.status}`);
|
|
const ct = (variantRes.headers.get('content-type') || '').toLowerCase();
|
|
if (ct.includes('mpegurl') || ct.includes('m3u8') || nextUrl.includes('.m3u8')) {
|
|
let segUrl = null;
|
|
const vLines = (await variantRes.text()).split('\n');
|
|
for (let i = 0; i < vLines.length; i++) {
|
|
const t = vLines[i].trim();
|
|
if (t && t.charCodeAt(0) !== 35) { segUrl = t; break; }
|
|
}
|
|
if (segUrl) {
|
|
if (!segUrl.startsWith('http')) segUrl = new URL(segUrl, nextUrl).href;
|
|
const segRes = await _nativeFetch(segUrl, { method: 'HEAD', signal: AbortSignal.timeout(5000), headers: fetchHeaders });
|
|
segRes.body?.cancel();
|
|
if (!segRes.ok && segRes.status !== 206) return fail(`Segment failed: ${segRes.status}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return store({ ok: true, error: null });
|
|
} catch (err) { return fail(err.message); }
|
|
}
|
|
|
|
async function getMetadata(id, s, e) {
|
|
const key = process.env.TMDB_API_KEY;
|
|
if (!key) return { error: 'TMDB API key not configured' };
|
|
|
|
const cleanId =
|
|
typeof id === 'object'
|
|
? id?.id ?? id?.tmdbId
|
|
: id;
|
|
|
|
if (!cleanId) return { error: 'Invalid TMDB id' };
|
|
|
|
const cacheKey = `meta-${cleanId}-${s ?? ''}-${e ?? ''}`;
|
|
|
|
return getSharedCached(cacheKey, async () => {
|
|
const url = s
|
|
? `https://api.themoviedb.org/3/tv/${cleanId}/season/${s}/episode/${e || 1}?api_key=${key}`
|
|
: `https://api.themoviedb.org/3/movie/${cleanId}?api_key=${key}`;
|
|
|
|
const res = await _nativeFetch(url, {
|
|
signal: AbortSignal.timeout(5_000),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
res.body?.cancel();
|
|
return { error: `TMDB API error: ${res.status}` };
|
|
}
|
|
|
|
return res.json();
|
|
}, 1_800_000);
|
|
}
|
|
|
|
function applyCdnHeaders(cleanUrl, extraHeaders, sourceKey) {
|
|
const cfg = sdk.getSources().find(c => c.key === sourceKey);
|
|
if (!cfg?.cdnHeaders) return;
|
|
for (const rule of cfg.cdnHeaders) {
|
|
if (rule.pattern.test(cleanUrl)) { Object.assign(extraHeaders, rule.headers); return; }
|
|
}
|
|
}
|
|
|
|
function fetchSource(cfg, cacheKey, id, s, e, clientIP, absoluteBase) {
|
|
function fixSource(url) {
|
|
if (url.includes('/api?url=')) {
|
|
return `${absoluteBase}/api?url=${encodeURIComponent(url)}`;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
function applyFix(result) {
|
|
if (result && Array.isArray(result.allUrls)) {
|
|
result.allUrls = result.allUrls.map(entry => ({
|
|
...entry,
|
|
url: fixSource(entry.url)
|
|
}));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
if (cfg.skipCache) {
|
|
return withTimeout(
|
|
jitter(cfg.jitter).then(() => withRetry(() => sdk.getStream(cfg.key, id, s, e, clientIP), cfg.retries, 300)),
|
|
cfg.timeout
|
|
).then(applyFix);
|
|
}
|
|
|
|
return withTimeout(
|
|
jitter(cfg.jitter).then(() =>
|
|
getSharedCached(`${cfg.key}-${cacheKey}`, () => withRetry(() => sdk.getStream(cfg.key, id, s, e, clientIP), cfg.retries, 300))
|
|
),
|
|
cfg.timeout
|
|
).then(applyFix);
|
|
}
|
|
|
|
function normalizeCandidates(rawResult) {
|
|
let candidates = [];
|
|
if (rawResult?.allUrls?.length) {
|
|
candidates = rawResult.allUrls.map(u => typeof u === 'object' ? u : { url: u });
|
|
} else if (rawResult?.streams?.length) {
|
|
candidates = rawResult.streams.map(u => typeof u === 'object' ? u : { url: u });
|
|
} else if (Array.isArray(rawResult)) {
|
|
candidates = rawResult.map(u => typeof u === 'object' ? u : { url: u });
|
|
} else if (rawResult) {
|
|
candidates = [{ url: typeof rawResult === 'object' ? rawResult.url : rawResult, headers: rawResult?.headers, skipProxy: rawResult?.skipProxy, skipHlsCheck: rawResult?.skipHlsCheck }];
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
const LOOPBACK = `http://127.0.0.1:${PORT}`;
|
|
const MAX_STREAMS_PER_SOURCE = 8;
|
|
const VERIFY_BUDGET_MS = 15_000;
|
|
|
|
function toLoopback(url, absoluteBase) {
|
|
const variants = new Set([
|
|
absoluteBase,
|
|
absoluteBase.replace(/^http:\/\//, 'https://'),
|
|
absoluteBase.replace(/^https:\/\//, 'http://'),
|
|
]);
|
|
for (const b of variants) {
|
|
if (url.startsWith(`${b}/api?`)) return LOOPBACK + url.slice(b.length);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
async function readHead(res, limit) {
|
|
const reader = res.body?.getReader();
|
|
if (!reader) return Buffer.alloc(0);
|
|
const chunks = [];
|
|
let total = 0;
|
|
try {
|
|
while (total < limit) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
chunks.push(value);
|
|
total += value.length;
|
|
}
|
|
} finally {
|
|
reader.cancel().catch(() => { });
|
|
}
|
|
return Buffer.concat(chunks).subarray(0, limit);
|
|
}
|
|
|
|
function looksLikeMedia(buf) {
|
|
if (buf.length < 8) return false;
|
|
if (buf[0] === 0x47 && (buf.length < 189 || buf[188] === 0x47)) return true;
|
|
const box = buf.subarray(4, 8).toString('latin1');
|
|
if (['ftyp', 'styp', 'moof', 'moov', 'sidx', 'mdat', 'free', 'wide'].includes(box)) return true;
|
|
if (buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) return true;
|
|
if (buf[0] === 0xFF && (buf[1] & 0xF0) === 0xF0) return true;
|
|
if (buf[0] === 0x1A && buf[1] === 0x45 && buf[2] === 0xDF && buf[3] === 0xA3) return true;
|
|
return false;
|
|
}
|
|
|
|
function firstUri(text, base) {
|
|
for (const line of text.split('\n')) {
|
|
const t = line.trim();
|
|
if (t && t.charCodeAt(0) !== 35) return new URL(t, base).href;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function verifyServed(finalUrl, headers, absoluteBase) {
|
|
const cacheKey = `served-${finalUrl}`;
|
|
const cached = hlsVerifyCache.get(cacheKey);
|
|
if (cached !== undefined) return cached;
|
|
const store = ok => { hlsVerifyCache.set(cacheKey, ok); return ok; };
|
|
|
|
const get = url => _nativeFetch(toLoopback(url, absoluteBase), {
|
|
headers: { 'User-Agent': getUA(), ...headers },
|
|
redirect: 'follow',
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
|
|
const accept = async (r, limit) => {
|
|
if (!r.ok && r.status !== 206) { r.body?.cancel(); return null; }
|
|
const ct = (r.headers.get('content-type') || '').toLowerCase();
|
|
if (ct.includes('text/html') || ct.includes('json')) { r.body?.cancel(); return null; }
|
|
return readHead(r, limit);
|
|
};
|
|
|
|
try {
|
|
let res = await get(finalUrl);
|
|
let buf = await accept(res, 65536);
|
|
if (!buf) return store(false);
|
|
let text = buf.toString('utf8');
|
|
let base = res.url || finalUrl;
|
|
|
|
if (!/^\s*#EXTM3U/.test(text)) {
|
|
if (text.includes('<MPD')) return store(true);
|
|
return store(looksLikeMedia(buf));
|
|
}
|
|
|
|
if (text.includes('#EXT-X-STREAM-INF')) {
|
|
const variantUrl = firstUri(text, base);
|
|
if (!variantUrl) return store(false);
|
|
res = await get(variantUrl);
|
|
buf = await accept(res, 65536);
|
|
if (!buf) return store(false);
|
|
text = buf.toString('utf8');
|
|
base = res.url || variantUrl;
|
|
if (!/^\s*#EXTM3U/.test(text) || text.includes('#EXT-X-STREAM-INF')) return store(false);
|
|
}
|
|
|
|
if (!text.includes('#EXTINF')) return store(false);
|
|
|
|
const keyLine = text.split('\n').find(l => /^#EXT-X-KEY:/.test(l.trim()) && !/METHOD=NONE/.test(l));
|
|
const encrypted = !!keyLine;
|
|
if (keyLine) {
|
|
const m = /URI="([^"]+)"/.exec(keyLine);
|
|
if (m && !m[1].startsWith('data:')) {
|
|
const kres = await get(new URL(m[1], base).href);
|
|
const kb = await accept(kres, 64);
|
|
if (!kb || kb.length !== 16) return store(false);
|
|
}
|
|
}
|
|
|
|
const segUrl = firstUri(text, base);
|
|
if (!segUrl) return store(false);
|
|
const sres = await get(segUrl);
|
|
const sb = await accept(sres, 4096);
|
|
if (!sb || sb.length < 188) return store(false);
|
|
|
|
if (encrypted) return store(!/^\s*[<{]/.test(sb.toString('latin1', 0, 16)));
|
|
return store(looksLikeMedia(sb));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function verifyCandidate(candidate, sourceKey, absoluteBase) {
|
|
if (!candidate?.url) return null;
|
|
const wrapped = wrapUrl(candidate, sourceKey, absoluteBase, sdk);
|
|
if (!wrapped) return null;
|
|
|
|
const viaProxy = wrapped !== candidate.url || toLoopback(wrapped, absoluteBase) !== wrapped;
|
|
const ok = await withTimeout(
|
|
verifyServed(wrapped, viaProxy ? {} : (candidate.headers ?? {}), absoluteBase),
|
|
VERIFY_BUDGET_MS
|
|
);
|
|
if (!ok) return null;
|
|
|
|
const out = { url: wrapped, raw_url: candidate.url, proxied: viaProxy };
|
|
if (!viaProxy && candidate.headers && Object.keys(candidate.headers).length) out.headers = candidate.headers;
|
|
for (const k of ['quality', 'type', 'name', 'language']) {
|
|
if (candidate[k] !== undefined) out[k] = candidate[k];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function handleTestSource(sdk, sourceKey, id, s, e, clientIP, host) {
|
|
const start = Date.now();
|
|
const cfg = sdk.getSources().find(c => c.key === sourceKey);
|
|
const absoluteBase = getAbsoluteBase(host);
|
|
|
|
const respond = result => ({
|
|
status: 200,
|
|
body: JSON.stringify({
|
|
source: sourceKey,
|
|
id,
|
|
s: s || null,
|
|
e: e || null,
|
|
ok: !!result?.ok,
|
|
url: result?.ok ? result.url : null,
|
|
streams: result?.ok ? result.streams : [],
|
|
raw_url: result?.raw_url ?? null,
|
|
elapsed_ms: Date.now() - start,
|
|
error: result?.ok ? null : (result?.error ?? null),
|
|
...(result?.debug ? { debug: result.debug } : {}),
|
|
}, null, 2),
|
|
contentType: 'application/json',
|
|
});
|
|
|
|
if (!cfg) return respond({ ok: false, error: 'unknown source' });
|
|
if (cfg.disabled) return respond({ ok: false, error: 'source disabled' });
|
|
|
|
const cacheKey = `test-${sourceKey}-${id}-${s ?? ''}-${e ?? ''}-${host}`;
|
|
const localCached = testResultCache.get(cacheKey);
|
|
if (localCached !== undefined) return respond(localCached);
|
|
|
|
const shared = await sharedCacheGet(cacheKey);
|
|
if (shared !== undefined) { testResultCache.set(cacheKey, shared); return respond(shared); }
|
|
|
|
const inflightKey = `inflight-${cacheKey}`;
|
|
const existing = sharedInflight.get(inflightKey);
|
|
if (existing) {
|
|
try {
|
|
return respond((await existing) ?? { ok: false, error: 'timed out' });
|
|
} catch {
|
|
return respond({ ok: false, error: 'deduped request failed' });
|
|
}
|
|
}
|
|
|
|
if (sharedInflight.size >= MAX_SHARED_INFLIGHT) {
|
|
return respond({ ok: false, error: 'server busy, try again shortly' });
|
|
}
|
|
|
|
await acquireTestSlot();
|
|
|
|
const testPromise = withTimeout((async () => {
|
|
try {
|
|
let rawResult = null, fetchError = null;
|
|
|
|
try {
|
|
rawResult = await fetchSource(cfg, `${id}-${s ?? ''}-${e ?? ''}`, id, s, e, clientIP, absoluteBase);
|
|
if (!rawResult) rawResult = await withTimeout(sdk.getStream(sourceKey, id, s, e), 20_000);
|
|
} catch (err) { fetchError = err.message; }
|
|
|
|
const seen = new Set();
|
|
const candidates = normalizeCandidates(rawResult)
|
|
.filter(c => {
|
|
const u = typeof c?.url === 'string' ? c.url : null;
|
|
if (!u || seen.has(u)) return false;
|
|
seen.add(u);
|
|
return true;
|
|
})
|
|
.slice(0, MAX_STREAMS_PER_SOURCE);
|
|
|
|
const verified = (await Promise.all(
|
|
candidates.map(c => verifyCandidate(c, sourceKey, absoluteBase).catch(() => null))
|
|
)).filter(Boolean);
|
|
|
|
if (!verified.length) {
|
|
return {
|
|
ok: false,
|
|
url: null,
|
|
raw_url: candidates[0]?.url || null,
|
|
error: fetchError || (candidates.length ? 'no playable streams' : 'no streams returned'),
|
|
};
|
|
}
|
|
|
|
const result = { ok: true, url: verified[0].url, raw_url: verified[0].raw_url, streams: verified };
|
|
if (!rawResult?.skipCache) {
|
|
testResultCache.set(cacheKey, result);
|
|
sharedCacheSet(cacheKey, result, cfg.testCacheTtl ?? 90_000);
|
|
}
|
|
return result;
|
|
} finally {
|
|
releaseTestSlot();
|
|
sharedInflight.delete(inflightKey);
|
|
}
|
|
})(), 45_000);
|
|
|
|
sharedInflight.set(inflightKey, testPromise);
|
|
const result = await testPromise;
|
|
return respond(result ?? { ok: false, error: 'timed out' });
|
|
}
|
|
|
|
async function streamSources(sources, id, s, e, clientIP, absoluteBase, res) {
|
|
const sent = new Set();
|
|
const host = absoluteBase.replace(/https?:\/\//, '');
|
|
const debugResults = [];
|
|
let closed = false;
|
|
|
|
const onClose = () => { closed = true; };
|
|
res.on('close', onClose);
|
|
res.on('error', onClose);
|
|
|
|
const safeWrite = data => {
|
|
if (closed || res.writableEnded || res.destroyed) return false;
|
|
try { res.write(data); return true; } catch { closed = true; return false; }
|
|
};
|
|
|
|
const promises = sources.map(async cfg => {
|
|
if (closed) return;
|
|
try {
|
|
const result = await handleTestSource(sdk, cfg.key, id, s, e, clientIP, host);
|
|
if (closed) return;
|
|
const parsed = JSON.parse(result.body);
|
|
debugResults.push({
|
|
source: cfg.key,
|
|
ok: parsed.ok,
|
|
streams: parsed.streams?.length ?? 0,
|
|
error: parsed.error || null,
|
|
elapsed_ms: parsed.elapsed_ms,
|
|
});
|
|
if (!parsed.ok) return;
|
|
|
|
const label = cfg.label ?? cfg.key;
|
|
let n = 0;
|
|
for (const stream of parsed.streams || []) {
|
|
if (sent.has(stream.url)) continue;
|
|
sent.add(stream.url);
|
|
n++;
|
|
const { raw_url, ...publicStream } = stream;
|
|
safeWrite(`data: ${JSON.stringify({
|
|
type: 'source',
|
|
source: {
|
|
source: cfg.key,
|
|
label: n > 1 ? `${label} ${n}` : label,
|
|
index: n - 1,
|
|
...publicStream,
|
|
},
|
|
})}\n\n`);
|
|
}
|
|
} catch (err) {
|
|
debugResults.push({ source: cfg.key, ok: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
await Promise.race([
|
|
Promise.all(promises),
|
|
new Promise(r => setTimeout(r, EARLY_CLOSE_MS)),
|
|
]);
|
|
|
|
safeWrite(`data: ${JSON.stringify({ type: 'debug', results: debugResults })}\n\n`);
|
|
return sent.size;
|
|
}
|
|
|
|
const respondJson = (status, data, extraHeaders) => ({
|
|
status,
|
|
body: JSON.stringify(data),
|
|
headers: extraHeaders ? { ...JSON_CORS, ...extraHeaders } : JSON_CORS,
|
|
});
|
|
|
|
async function handleRequest(req, res) {
|
|
const baseUrl = `http://${req.headers.host || 'localhost'}`;
|
|
const reqUrl = new URL(req.url, baseUrl);
|
|
const { pathname, searchParams } = reqUrl;
|
|
const clientIP = req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket?.remoteAddress || null;
|
|
|
|
if (BLOCKED_IPS.size && BLOCKED_IPS.has(clientIP)) return respondJson(403, { error: 'forbidden' });
|
|
|
|
if (req.method === 'OPTIONS') return { status: 204, body: '', headers: CORS_HEADERS };
|
|
|
|
if (pathname === '/' || pathname === '') {
|
|
return {
|
|
status: 200,
|
|
body: `${LOGO_TEXT}\n\ndeveloped_by: @vyla-entertainment\ngitlab: https://gitlab.com/vyla-entertainment\ndocs: https://docs.vyla.cc\ndmca: https://docs.vyla.cc/misc/dmca`,
|
|
headers: { 'Content-Type': 'text/plain; charset=utf-8', ...CORS_HEADERS },
|
|
};
|
|
}
|
|
|
|
if (pathname === '/test' || pathname === '/api/test') {
|
|
const tests = {};
|
|
ACTIVE_SOURCES.forEach(s => {
|
|
tests[s.key] = {
|
|
movie: `/api/test/155?source=${s.key}`,
|
|
tv: `/api/test/1396?season=1&episode=1&source=${s.key}`
|
|
};
|
|
});
|
|
return respondJson(200, tests);
|
|
}
|
|
|
|
if (pathname === '/health' || pathname === '/api/health') {
|
|
const result = await handleHealth(sdk, mainCache);
|
|
return { ...result, headers: { ...result.headers, ...CORS_HEADERS } };
|
|
}
|
|
|
|
const absoluteBase = getAbsoluteBase(reqUrl.host);
|
|
const getRequestedSources = () => {
|
|
const raw = searchParams.get('sources')?.split(',').map(s => s.trim()).filter(Boolean) ?? [];
|
|
return raw.length ? ACTIVE_SOURCES.filter(s => raw.includes(s.key)) : ACTIVE_SOURCES;
|
|
};
|
|
|
|
const getRequestMeta = () => {
|
|
const raw = {
|
|
ip: clientIP,
|
|
referer: req.headers['referer'] || null,
|
|
origin: req.headers['origin'] || null,
|
|
user_agent: req.headers['user-agent'] || null,
|
|
host: req.headers['host'] || null,
|
|
country: req.headers['cf-ipcountry'] || null,
|
|
path: pathname,
|
|
query: reqUrl.search || null,
|
|
};
|
|
return Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== null && v !== undefined));
|
|
};
|
|
|
|
const googleAnalytic = (event, extra = {}) => {
|
|
if (!GA_MEASUREMENT_ID || !GA_API_SECRET) return;
|
|
const gaClientId = (clientIP && clientIP !== '127.0.0.1' && clientIP !== '::1' && clientIP !== '::ffff:127.0.0.1')
|
|
? clientIP
|
|
: `local.${Date.now()}.${Math.random().toString(36).slice(2, 9)}`;
|
|
const safeName = event.replace(/-/g, '_');
|
|
const meta = getRequestMeta();
|
|
sendGAEvent(safeName, { ...meta, ...extra }, gaClientId);
|
|
};
|
|
|
|
if (pathname === '/movie' || pathname === '/api/movie') {
|
|
const id = searchParams.get('id');
|
|
if (!id) return respondJson(400, { error: 'missing id', route: '/movie?id=:tmdb_id', example: '/movie?id=155' });
|
|
|
|
const tmdbValidation = await validateTmdbId(id, 'movie');
|
|
if (!tmdbValidation.valid) {
|
|
return respondJson(400, { error: tmdbValidation.error });
|
|
}
|
|
|
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', ...CORS_HEADERS });
|
|
const [meta, subtitles] = await Promise.all([
|
|
getMetadata(id, null, null),
|
|
sdk.getSubtitles(id, null, null)
|
|
]);
|
|
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
try { res.write(`data: ${JSON.stringify({ type: 'meta', meta, subtitles })}\n\n`); } catch { return null; }
|
|
}
|
|
|
|
googleAnalytic('stream_movie', { id });
|
|
const total = await streamSources(getRequestedSources(), id, null, null, clientIP, absoluteBase, res);
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
try { res.write(`data: ${JSON.stringify({ type: 'done', total })}\n\n`); res.end(); } catch { }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (pathname === '/tv' || pathname === '/api/tv') {
|
|
const id = searchParams.get('id'), s = searchParams.get('season'), e = searchParams.get('episode');
|
|
if (!id || !s || !e) return respondJson(400, { error: 'missing parameters', route: '/tv?id=:id&season=:s&episode=:e', example: '/tv?id=1396&season=1&episode=1' });
|
|
|
|
const tmdbValidation = await validateTmdbId(id, 'tv');
|
|
if (!tmdbValidation.valid) {
|
|
return respondJson(400, { error: tmdbValidation.error });
|
|
}
|
|
|
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', ...CORS_HEADERS });
|
|
|
|
const [meta, subtitles] = await Promise.all([
|
|
getMetadata(id, s, e),
|
|
sdk.getSubtitles(id, s, e)
|
|
]);
|
|
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
try { res.write(`data: ${JSON.stringify({ type: 'meta', meta, subtitles })}\n\n`); } catch { return null; }
|
|
}
|
|
|
|
googleAnalytic('stream_tv', { id, season: s, episode: e });
|
|
const total = await streamSources(getRequestedSources(), id, s, e, clientIP, absoluteBase, res);
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
try { res.write(`data: ${JSON.stringify({ type: 'done', total })}\n\n`); res.end(); } catch { }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (pathname === '/subtitle' || pathname === '/subtitles' || pathname === '/api/subtitle' || pathname === '/api/subtitles') {
|
|
return respondJson(200, { routes: { movie: '/subtitles/movie/:id', tv: '/subtitles/tv/:id/:s/:e' }, examples: { movie: '/subtitles/movie/155', tv: '/subtitles/tv/1396/1/1' } });
|
|
}
|
|
|
|
if (pathname === '/download' || pathname === '/downloads' || pathname === '/api/download' || pathname === '/api/downloads') {
|
|
return respondJson(200, { routes: { movie: '/downloads/movie/:id', tv: '/downloads/tv/:id/:s/:e' }, examples: { movie: '/downloads/movie/155', tv: '/downloads/tv/1396/1/1' } });
|
|
}
|
|
|
|
let match;
|
|
|
|
match = ROUTE_PATTERNS.subtitleMovie.exec(pathname);
|
|
if (match) {
|
|
googleAnalytic('subtitles_movie', { id: match[1] });
|
|
return handleSubtitleMovie(match[1], CORS_HEADERS, sdk);
|
|
}
|
|
|
|
match = ROUTE_PATTERNS.subtitleTv.exec(pathname);
|
|
if (match) {
|
|
googleAnalytic('subtitles_tv', { id: match[1], season: match[2], episode: match[3] });
|
|
return handleSubtitleTv(match[1], match[2], match[3], CORS_HEADERS, sdk);
|
|
}
|
|
|
|
match = ROUTE_PATTERNS.downloadMovie.exec(pathname);
|
|
if (match) {
|
|
googleAnalytic('downloads_movie', { id: match[1] });
|
|
return handleDownloadMovie(match[1], CORS_HEADERS, sdk);
|
|
}
|
|
|
|
match = ROUTE_PATTERNS.downloadTv.exec(pathname);
|
|
if (match) {
|
|
googleAnalytic('downloads_tv', { id: match[1], season: match[2], episode: match[3] });
|
|
return handleDownloadTv(match[1], match[2], match[3], CORS_HEADERS, sdk);
|
|
}
|
|
|
|
match = ROUTE_PATTERNS.test.exec(pathname);
|
|
if (match) {
|
|
const result = await handleTestRoute(sdk, match, searchParams, clientIP, reqUrl.host, handleTestSource, googleAnalytic);
|
|
return { status: result.status, body: result.body, headers: JSON_CORS };
|
|
}
|
|
|
|
match = ENABLE_DEBUG_ROUTE ? ROUTE_PATTERNS.debug.exec(pathname) : null;
|
|
if (match) {
|
|
const result = await handleDebugRoute(sdk, match, searchParams, absoluteBase, _nativeFetch, verifyPlayable);
|
|
return { status: result.status, body: result.body, headers: JSON_CORS };
|
|
}
|
|
|
|
if (pathname === '/api' || pathname === '/api/') {
|
|
const url = searchParams.get('url') || searchParams.get('proxy');
|
|
|
|
if (url) {
|
|
if (IS_HF && searchParams.get('lm') === '1') {
|
|
return { status: 302, body: '', headers: { 'Location': FALLBACK_BASE + req.url, ...CORS_HEADERS } };
|
|
}
|
|
|
|
try {
|
|
new URL(url);
|
|
|
|
const extraHeaders = {};
|
|
const proxyHeaders = searchParams.get('proxyHeaders');
|
|
if (proxyHeaders) try { Object.assign(extraHeaders, JSON.parse(safeDecode(proxyHeaders))); } catch { }
|
|
if (!extraHeaders['User-Agent'] && !extraHeaders['user-agent']) extraHeaders['User-Agent'] = getUA();
|
|
const vlHost = extraHeaders['x-vl-host'];
|
|
delete extraHeaders['Host'];
|
|
delete extraHeaders['x-vl-host'];
|
|
if (vlHost) extraHeaders['Host'] = vlHost;
|
|
let matchedSource = null;
|
|
for (const [param, cfg] of PROXY_PARAM_MAP) {
|
|
if (searchParams.has(param)) { matchedSource = cfg; break; }
|
|
}
|
|
|
|
let cleanUrl = url;
|
|
if (matchedSource) {
|
|
try {
|
|
const qIndex = url.indexOf('?');
|
|
if (qIndex !== -1) {
|
|
const params = new URLSearchParams(url.slice(qIndex + 1));
|
|
const headersParam = params.get('headers');
|
|
if (headersParam) {
|
|
try { Object.assign(extraHeaders, JSON.parse(decodeURIComponent(headersParam))); } catch { }
|
|
params.delete('headers');
|
|
}
|
|
cleanUrl = `${url.slice(0, qIndex)}${params.toString() ? '?' + params.toString() : ''}`;
|
|
}
|
|
} catch { }
|
|
applyCdnHeaders(cleanUrl, extraHeaders, matchedSource.key);
|
|
}
|
|
|
|
const upstream = await fetchUpstream(cleanUrl, extraHeaders, 30_000);
|
|
const ct = (upstream.headers.get('content-type') || '').toLowerCase();
|
|
const looksLikeM3u8 = M3U8_REGEX.test(cleanUrl) || cleanUrl.includes('/playlist/') || cleanUrl.includes('/streamsvr/') || ct.includes('mpegurl') || ct.includes('m3u8');
|
|
|
|
if (looksLikeM3u8) {
|
|
const text = await upstream.text();
|
|
if (text.trim().startsWith('#EXT') || /megacloud\.animanga\.fun\/(ts-proxy|proxy)/i.test(text.slice(0, 200))) {
|
|
const isTesub = matchedSource?.proxyParam === 'tesub';
|
|
const extraParam = matchedSource ? `&${matchedSource.proxyParam}=1&proxyHeaders=${encodeURIComponent(JSON.stringify(extraHeaders))}` : '&vn=1';
|
|
const rewritten = isTesub
|
|
? rewriteM3u8KeyOnly(text, cleanUrl, extraParam, absoluteBase)
|
|
: rewriteM3u8(text, matchedSource ? cleanUrl : url, extraParam, absoluteBase);
|
|
return { status: 200, body: rewritten, headers: { 'Content-Type': 'application/vnd.apple.mpegurl', ...CORS_HEADERS } };
|
|
}
|
|
return { status: 502, body: `expected m3u8 but got: ${text.slice(0, 100)}`, headers: { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' } };
|
|
}
|
|
|
|
if (matchedSource) {
|
|
const isTikTok = TIKTOK_REGEX.test(cleanUrl);
|
|
const isMkv = cleanUrl.includes('.mkv') || ct.includes('matroska');
|
|
const isPngMasked = ct === 'image/png' || ct === 'image/jpeg' || /\.png(\?|$)/i.test(cleanUrl) || /letsgocdn\d+\.shop/i.test(cleanUrl);
|
|
const needsStrip = searchParams.has('tt') || STRIP_REGEX.test(cleanUrl);
|
|
|
|
const isKey = cleanUrl.endsWith('.key') || ct.includes('octet-stream') && cleanUrl.includes('mon.key');
|
|
if (isKey) {
|
|
if (!upstream.ok) return { status: upstream.status, body: `upstream ${upstream.status}`, headers: { 'Content-Type': 'text/plain', ...CORS_HEADERS } };
|
|
const keyBytes = Buffer.from(await upstream.arrayBuffer());
|
|
return { status: 200, body: keyBytes, headers: { 'Content-Type': 'application/octet-stream', ...CORS_HEADERS } };
|
|
}
|
|
|
|
if (isTikTok || isPngMasked || needsStrip) {
|
|
if (!upstream.ok) return { status: upstream.status, body: `upstream ${upstream.status}`, headers: { 'Content-Type': 'text/plain', ...CORS_HEADERS } };
|
|
const full = Buffer.from(await upstream.arrayBuffer());
|
|
const stripped = (full[0] === 0x89 || full[0] === 0xFF || full[0] === 0x00) ? full.subarray(120) : full;
|
|
return { status: 200, body: stripped, headers: { 'Content-Type': 'video/MP2T', ...CORS_HEADERS, 'Cache-Control': 'public, max-age=3600' } };
|
|
}
|
|
|
|
if (!upstream.ok) return { status: upstream.status, body: `upstream ${upstream.status}`, headers: { 'Content-Type': 'text/plain', ...CORS_HEADERS } };
|
|
|
|
const rangeHeader = req.headers['range'];
|
|
const streamUpstream = rangeHeader
|
|
? await _nativeFetch(cleanUrl, { headers: { 'User-Agent': getUA(), ...extraHeaders, 'Range': rangeHeader }, redirect: 'follow' })
|
|
: upstream;
|
|
|
|
const responseHeaders = {
|
|
'Content-Type': isMkv || ct === 'application/octet-stream' ? 'video/mp4' : (ct || 'video/mp4'),
|
|
'Accept-Ranges': 'bytes',
|
|
'Cache-Control': 'no-store',
|
|
...CORS_HEADERS,
|
|
};
|
|
if (streamUpstream.headers.has('content-length')) responseHeaders['Content-Length'] = streamUpstream.headers.get('content-length');
|
|
if (streamUpstream.headers.has('content-range')) responseHeaders['Content-Range'] = streamUpstream.headers.get('content-range');
|
|
|
|
return { status: rangeHeader && streamUpstream.status === 206 ? 206 : 200, stream: streamUpstream.body, headers: responseHeaders };
|
|
}
|
|
|
|
const full = Buffer.from(await upstream.arrayBuffer());
|
|
const needsStrip = searchParams.has('tt') || TIKTOK_REGEX.test(url) || STRIP_REGEX.test(url);
|
|
const stripped = (needsStrip && (full[0] === 0x89 || full[0] === 0xFF || full[0] === 0x00)) ? full.subarray(120) : full;
|
|
return { status: 200, body: stripped, headers: { 'Content-Type': 'video/MP2T', ...CORS_HEADERS, 'Cache-Control': 'public, max-age=3600' } };
|
|
} catch (e) {
|
|
return respondJson(502, { error: e.message });
|
|
}
|
|
}
|
|
|
|
if (searchParams.has('sources_meta')) {
|
|
return respondJson(200, { sources: ACTIVE_SOURCES.map(c => ({ key: c.key, label: c.label, timeout: c.timeout })) });
|
|
}
|
|
|
|
if (searchParams.has('tmdb_movie') || searchParams.has('tmdb_tv') || searchParams.has('tmdb_show') || searchParams.has('tmdb_season')) {
|
|
const k = process.env.TMDB_API_KEY;
|
|
if (!k) return respondJson(500, { error: 'no key' });
|
|
const tmdbId = searchParams.get('id'), tmdbSeason = searchParams.get('s');
|
|
let tmdbUrl;
|
|
if (searchParams.has('tmdb_season')) tmdbUrl = `https://api.themoviedb.org/3/tv/${tmdbId}/season/${tmdbSeason}?api_key=${k}`;
|
|
else if (searchParams.has('tmdb_movie')) tmdbUrl = `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${k}${searchParams.has('append_to_response') ? `&append_to_response=${searchParams.get('append_to_response')}` : ''}`;
|
|
else tmdbUrl = `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=${k}`;
|
|
try { const r = await _nativeFetch(tmdbUrl); return respondJson(200, await r.json()); }
|
|
catch (err) { return respondJson(500, { error: err.message }); }
|
|
}
|
|
|
|
return respondJson(400, { error: 'missing parameters' });
|
|
}
|
|
|
|
return respondJson(404, { error: 'not found' });
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
req.socket.setTimeout(90_000);
|
|
req.socket.setNoDelay(true);
|
|
|
|
try {
|
|
const result = await handleRequest(req, res);
|
|
if (result === null || res.headersSent || res.writableEnded || res.destroyed) return;
|
|
|
|
const headers = result.headers || {};
|
|
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
|
res.writeHead(result.status, headers);
|
|
|
|
if (result.stream) {
|
|
const readable = Readable.fromWeb(result.stream);
|
|
readable.on('error', () => { try { res.destroy(); } catch { } });
|
|
res.on('error', () => { try { readable.destroy(); } catch { } });
|
|
readable.pipe(res);
|
|
} else {
|
|
res.end(result.body ?? '');
|
|
}
|
|
} catch (err) {
|
|
if (!res.headersSent) {
|
|
console.error(err)
|
|
try { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end('{"error":"internal server error"}'); } catch { }
|
|
}
|
|
}
|
|
});
|
|
|
|
server.keepAliveTimeout = 65_000;
|
|
server.headersTimeout = 70_000;
|
|
server.maxHeadersCount = 100;
|
|
server.timeout = 90_000;
|
|
|
|
server.on('error', err => { if (err.code !== 'EADDRINUSE') console.error('server error', err.message); });
|
|
await validateGAConfig();
|
|
server.listen(PORT, '0.0.0.0', () => console.log(`http://localhost:${PORT}`));
|