mirror of
https://github.com/Inside4ndroid/TMDB-Embed-API.git
synced 2026-10-22 01:25:10 +00:00
chore(release): 1.0.8
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
// Lightweight proxy/m3u8/segment/subtitle rewriting layer. thanks to https://github.com/cinepro-org/ for the original code.
|
||||
// Adapted from external project (user supplied) to fit CommonJS style and existing config system.
|
||||
// Provides conditional route registration via enableProxy flag.
|
||||
|
||||
const cors = require('cors');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
const CACHE_MAX_SIZE = 2000;
|
||||
const CACHE_EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||
const segmentCache = new Map();
|
||||
// Track first open-ended (bytes=0-) range per target to clamp only once per TTL window
|
||||
const openRangeClampMap = new Map();
|
||||
const OPEN_RANGE_CLAMP_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
// Progressive open-ended growth tracking: keeps expanding bytes=0- served window.
|
||||
const progressiveOpenMap = new Map(); // url -> { lastEnd }
|
||||
// Tail prefetch cache: stores last N KB for quick tail range responses
|
||||
const tailPrefetchMap = new Map(); // url -> { data: Buffer, start: number, end: number, size: number, ts: number }
|
||||
const TAIL_PREFETCH_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function isCacheDisabled() {
|
||||
return process.env.DISABLE_CACHE === 'true';
|
||||
}
|
||||
|
||||
function cleanupCache() {
|
||||
const now = Date.now();
|
||||
for (const [url, entry] of segmentCache.entries()) {
|
||||
if (now - entry.timestamp > CACHE_EXPIRY_MS) segmentCache.delete(url);
|
||||
}
|
||||
if (segmentCache.size > CACHE_MAX_SIZE) {
|
||||
const entries = Array.from(segmentCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp);
|
||||
const toRemove = entries.slice(0, segmentCache.size - CACHE_MAX_SIZE);
|
||||
toRemove.forEach(([u]) => segmentCache.delete(u));
|
||||
}
|
||||
return segmentCache.size;
|
||||
}
|
||||
setInterval(cleanupCache, 30 * 60 * 1000).unref();
|
||||
|
||||
function cleanupClampMap() {
|
||||
const now = Date.now();
|
||||
for (const [url, ts] of openRangeClampMap.entries()) {
|
||||
if (now - ts > OPEN_RANGE_CLAMP_TTL_MS) openRangeClampMap.delete(url);
|
||||
}
|
||||
}
|
||||
setInterval(cleanupClampMap, 10 * 60 * 1000).unref();
|
||||
|
||||
function cleanupTailPrefetch() {
|
||||
const now = Date.now();
|
||||
for (const [url, entry] of tailPrefetchMap.entries()) {
|
||||
if (now - entry.ts > TAIL_PREFETCH_TTL_MS) tailPrefetchMap.delete(url);
|
||||
}
|
||||
}
|
||||
setInterval(cleanupTailPrefetch, 15 * 60 * 1000).unref();
|
||||
|
||||
function getCachedSegment(url) {
|
||||
if (isCacheDisabled()) return null;
|
||||
const e = segmentCache.get(url);
|
||||
if (!e) return null;
|
||||
if (Date.now() - e.timestamp > CACHE_EXPIRY_MS) { segmentCache.delete(url); return null; }
|
||||
return e;
|
||||
}
|
||||
|
||||
async function prefetchSegment(url, headers) {
|
||||
if (isCacheDisabled() || segmentCache.size >= CACHE_MAX_SIZE) return;
|
||||
const existing = segmentCache.get(url);
|
||||
if (existing && Date.now() - existing.timestamp <= CACHE_EXPIRY_MS) return;
|
||||
try {
|
||||
const resp = await fetch(url, { headers: { 'User-Agent': DEFAULT_UA, ...headers } });
|
||||
if (!resp.ok) return;
|
||||
const data = new Uint8Array(await resp.arrayBuffer());
|
||||
const responseHeaders = {};
|
||||
resp.headers.forEach((v, k) => responseHeaders[k] = v);
|
||||
segmentCache.set(url, { data, headers: responseHeaders, timestamp: Date.now() });
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
|
||||
const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
|
||||
function inferContentType(upstreamCT, targetUrl) {
|
||||
const bad = !upstreamCT || /application\/octet-stream/i.test(upstreamCT) || /application\/(x-)?zip/i.test(upstreamCT);
|
||||
if (!bad) return upstreamCT;
|
||||
if (/\.mkv(\?|$)/i.test(targetUrl)) return 'video/x-matroska';
|
||||
if (/\.mp4(\?|$)/i.test(targetUrl)) return 'video/mp4';
|
||||
if (/\.m3u8(\?|$)/i.test(targetUrl)) return 'application/vnd.apple.mpegurl';
|
||||
if (/\.ts(\?|$)/i.test(targetUrl)) return 'video/mp2t';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function extractOriginalUrl(proxyUrl) {
|
||||
try {
|
||||
const url = new URL(proxyUrl);
|
||||
if (url.pathname.includes('/proxy/')) {
|
||||
const m = url.pathname.match(/\/proxy\/(.+)$/);
|
||||
if (m) {
|
||||
let decoded = decodeURIComponent(m[1]);
|
||||
while (decoded.includes('%2F')) {
|
||||
try { decoded = decodeURIComponent(decoded); } catch { break; }
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
if (url.searchParams.has('url')) return decodeURIComponent(url.searchParams.get('url'));
|
||||
const patterns = [
|
||||
/\/api\/[^/]+\/proxy\?url=(.+)$/,
|
||||
/\/proxy\?.*url=([^&]+)/,
|
||||
/\/stream\/proxy\/(.+)$/,
|
||||
/\/p\/(.+)$/
|
||||
];
|
||||
for (const p of patterns) {
|
||||
const m = proxyUrl.match(p); if (m) return decodeURIComponent(m[1]);
|
||||
}
|
||||
return proxyUrl;
|
||||
} catch { return proxyUrl; }
|
||||
}
|
||||
|
||||
function rewriteM3u8(content, targetUrl, baseProxyUrl, headers) {
|
||||
const lines = content.split('\n');
|
||||
const out = []; const segmentUrls = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#')) {
|
||||
if (line.startsWith('#EXT-X-KEY:')) {
|
||||
const regex = /https?:\/\/[^""\s]+/g; const keyUrl = regex.exec(line)?.[0];
|
||||
if (keyUrl) {
|
||||
const proxyUrl = `${baseProxyUrl}/ts-proxy?url=${encodeURIComponent(keyUrl)}&headers=${encodeURIComponent(JSON.stringify(headers))}`;
|
||||
out.push(line.replace(keyUrl, proxyUrl));
|
||||
if (!isCacheDisabled()) prefetchSegment(keyUrl, headers);
|
||||
} else out.push(line);
|
||||
} else if (line.startsWith('#EXT-X-MEDIA:') || line.startsWith('#EXT-X-I-FRAME-STREAM-INF:')) {
|
||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||
if (uriMatch) {
|
||||
try {
|
||||
const mediaUrl = new URL(uriMatch[1], targetUrl).href;
|
||||
const proxyUrl = `${baseProxyUrl}/m3u8-proxy?url=${encodeURIComponent(mediaUrl)}&headers=${encodeURIComponent(JSON.stringify(headers))}`;
|
||||
out.push(line.replace(uriMatch[1], proxyUrl));
|
||||
} catch { out.push(line); }
|
||||
} else out.push(line);
|
||||
} else out.push(line);
|
||||
} else if (line.trim()) {
|
||||
try {
|
||||
const abs = new URL(line, targetUrl).href;
|
||||
if (/\.m3u8(\?|$)/i.test(abs)) {
|
||||
out.push(`${baseProxyUrl}/m3u8-proxy?url=${encodeURIComponent(abs)}&headers=${encodeURIComponent(JSON.stringify(headers))}`);
|
||||
} else if (/\.ts(\?|$)/i.test(abs)) {
|
||||
segmentUrls.push(abs);
|
||||
out.push(`${baseProxyUrl}/ts-proxy?url=${encodeURIComponent(abs)}&headers=${encodeURIComponent(JSON.stringify(headers))}`);
|
||||
} else out.push(line);
|
||||
} catch { out.push(line); }
|
||||
} else out.push(line);
|
||||
}
|
||||
if (segmentUrls.length && !isCacheDisabled()) {
|
||||
Promise.all(segmentUrls.map(u => prefetchSegment(u, headers))).catch(() => undefined);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function createProxyRoutes(app) {
|
||||
// m3u8 playlist proxy
|
||||
app.get('/m3u8-proxy', cors(), async (req, res) => {
|
||||
const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'URL parameter required' });
|
||||
let headers = {};
|
||||
try { headers = JSON.parse(req.query.headers || '{}'); } catch {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
try {
|
||||
const response = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers } });
|
||||
if (!response.ok) return res.status(response.status).json({ error: `M3U8 fetch failed: ${response.status}` });
|
||||
const text = await response.text();
|
||||
const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'http';
|
||||
const baseProxyUrl = `${protocol}://${req.get('host')}`;
|
||||
const rewritten = rewriteM3u8(text, targetUrl, baseProxyUrl, headers);
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.send(rewritten);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ts / key segment proxy
|
||||
app.get('/ts-proxy', cors(), async (req, res) => {
|
||||
const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'URL parameter required' });
|
||||
const debug = req.query.debug === '1';
|
||||
const noSynth = req.query.noSynth === '1';
|
||||
const force200 = req.query.force200 === '1';
|
||||
const clampOpen = req.query.clampOpen !== '0';
|
||||
const progressiveOpen = req.query.progressiveOpen !== '0';
|
||||
const tailPrefetchEnabled = req.query.tailPrefetch !== '0';
|
||||
let tailPrefetchKB = parseInt(req.query.tailPrefetchKB || '256', 10);
|
||||
if (isNaN(tailPrefetchKB) || tailPrefetchKB < 64) tailPrefetchKB = 256;
|
||||
if (tailPrefetchKB > 2048) tailPrefetchKB = 2048; // max 2MB tail window
|
||||
let openChunkKB = parseInt(req.query.openChunkKB || '4096', 10);
|
||||
if (isNaN(openChunkKB) || openChunkKB < 64) openChunkKB = 4096;
|
||||
if (openChunkKB > 16384) openChunkKB = 16384; // cap at 16MB
|
||||
let initChunkKB = parseInt(req.query.initChunkKB || '512', 10);
|
||||
if (isNaN(initChunkKB) || initChunkKB < 64) initChunkKB = 512;
|
||||
if (initChunkKB > 2048) initChunkKB = 2048; // hard cap 2MB
|
||||
let headers = {}; try { headers = JSON.parse(req.query.headers || '{}'); } catch {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
// Pass through Range header for progressive playback / seeking
|
||||
const range = req.headers['range'];
|
||||
let appliedClamp = false;
|
||||
let effectiveRange = range;
|
||||
if (range) headers.Range = range;
|
||||
// Clamp first open-ended bytes=0- request to a bounded span (once per TTL) to avoid gigantic initial 206
|
||||
if (range && /^bytes=0-\s*$/i.test(range) && !force200) {
|
||||
const now = Date.now();
|
||||
// Progressive growth supersedes single-shot clamp if enabled
|
||||
if (progressiveOpen) {
|
||||
const prog = progressiveOpenMap.get(targetUrl) || { lastEnd: -1 };
|
||||
const prevEnd = prog.lastEnd;
|
||||
let nextEnd;
|
||||
if (prog.lastEnd < 0) {
|
||||
nextEnd = openChunkKB * 1024 - 1;
|
||||
} else {
|
||||
const increment = openChunkKB * 1024;
|
||||
nextEnd = prog.lastEnd + increment;
|
||||
const maxCap = 256 * 1024 * 1024 - 1; // 256MB
|
||||
if (nextEnd > maxCap) nextEnd = maxCap;
|
||||
}
|
||||
const newRange = `bytes=0-${nextEnd}`;
|
||||
headers.Range = newRange;
|
||||
effectiveRange = newRange;
|
||||
prog.lastEnd = nextEnd;
|
||||
progressiveOpenMap.set(targetUrl, prog);
|
||||
appliedClamp = true; // reuse flag for logging
|
||||
if (debug) console.log('[ts-proxy] progressiveOpen expand', { prevEnd, nextEnd, increment: openChunkKB * 1024 });
|
||||
} else if (clampOpen) {
|
||||
const lastTs = openRangeClampMap.get(targetUrl);
|
||||
if (!lastTs || (now - lastTs) > OPEN_RANGE_CLAMP_TTL_MS) {
|
||||
const clampBytes = openChunkKB * 1024;
|
||||
const newRange = `bytes=0-${clampBytes - 1}`;
|
||||
headers.Range = newRange;
|
||||
effectiveRange = newRange;
|
||||
openRangeClampMap.set(targetUrl, now);
|
||||
appliedClamp = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (debug) console.log('[ts-proxy] incoming', { url: targetUrl, range, effectiveRange, appliedClamp, clampOpen, progressiveOpen, openChunkKB, noSynth, initChunkKB, force200, tailPrefetchEnabled, tailPrefetchKB });
|
||||
if (!isCacheDisabled()) {
|
||||
const cached = getCachedSegment(targetUrl);
|
||||
if (cached) {
|
||||
const ct = inferContentType(cached.headers['content-type'], targetUrl);
|
||||
res.setHeader('Content-Type', ct);
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
if (debug) console.log('[ts-proxy] cache hit');
|
||||
return res.send(Buffer.from(cached.data));
|
||||
}
|
||||
}
|
||||
try {
|
||||
// If we have a tail prefetch entry and the client requests a tail range fully inside it, serve from memory early.
|
||||
if (effectiveRange && /^bytes=\d+-$/i.test(effectiveRange)) {
|
||||
const tailEntry = tailPrefetchMap.get(targetUrl);
|
||||
if (tailEntry) {
|
||||
const m = effectiveRange.match(/bytes=(\d+)-/i);
|
||||
if (m) {
|
||||
const startReq = parseInt(m[1], 10);
|
||||
if (!isNaN(startReq) && startReq >= tailEntry.start && startReq <= tailEntry.end) {
|
||||
const offset = startReq - tailEntry.start;
|
||||
const slice = tailEntry.data.subarray(offset);
|
||||
const endByte = tailEntry.end;
|
||||
res.status(206);
|
||||
const ct = inferContentType(null, targetUrl);
|
||||
res.setHeader('Content-Type', ct);
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Content-Length', slice.length.toString());
|
||||
res.setHeader('Content-Range', `bytes ${startReq}-${endByte}/${tailEntry.size}`);
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (debug) console.log('[ts-proxy] tail serve hit', { startReq, cachedStart: tailEntry.start, cachedEnd: tailEntry.end, size: tailEntry.size });
|
||||
return res.end(slice);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HEAD preflight (only when no explicit Range requested) to get size for synthesized partial response
|
||||
let contentLength = null;
|
||||
let upstreamAcceptRanges = null;
|
||||
if (!effectiveRange) {
|
||||
try {
|
||||
const headResp = await fetch(targetUrl, { method: 'HEAD', headers: { 'User-Agent': DEFAULT_UA, ...headers } });
|
||||
if (headResp.ok) {
|
||||
contentLength = headResp.headers.get('content-length');
|
||||
upstreamAcceptRanges = headResp.headers.get('accept-ranges');
|
||||
if (debug) console.log('[ts-proxy] HEAD ok', { contentLength, upstreamAcceptRanges });
|
||||
} else if (debug) {
|
||||
console.log('[ts-proxy] HEAD status', headResp.status);
|
||||
}
|
||||
} catch (_e) { if (debug) console.log('[ts-proxy] HEAD failed'); }
|
||||
}
|
||||
|
||||
// Fallback probe: if still no size, attempt a 0-0 range request to derive total size from Content-Range
|
||||
if (!effectiveRange && !contentLength) {
|
||||
try {
|
||||
const probeRange = 'bytes=0-0';
|
||||
if (debug) console.log('[ts-proxy] probe range');
|
||||
const probeResp = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers, 'Range': probeRange } });
|
||||
if (probeResp.status === 206) {
|
||||
const cr = probeResp.headers.get('content-range');
|
||||
if (cr) {
|
||||
const m = cr.match(/\/(\d+)$/); if (m) contentLength = m[1];
|
||||
}
|
||||
try { await probeResp.arrayBuffer(); } catch (readErr) {
|
||||
if (debug) console.log('[ts-proxy] probe body read failed', readErr.message);
|
||||
}
|
||||
if (debug) console.log('[ts-proxy] probe success', { contentLength });
|
||||
} else if (debug) {
|
||||
console.log('[ts-proxy] probe status', probeResp.status);
|
||||
}
|
||||
} catch (_e) { if (debug) console.log('[ts-proxy] probe failed'); }
|
||||
}
|
||||
if (debug && contentLength) console.log('[ts-proxy] size determined', contentLength);
|
||||
|
||||
// Initiate tail prefetch asynchronously (once) when size known and feature enabled
|
||||
if (tailPrefetchEnabled && contentLength && !tailPrefetchMap.has(targetUrl)) {
|
||||
const total = parseInt(contentLength, 10);
|
||||
if (!isNaN(total) && total > 0) {
|
||||
const tailBytes = Math.min(tailPrefetchKB * 1024, total);
|
||||
const start = Math.max(0, total - tailBytes);
|
||||
const tailRange = `bytes=${start}-`;
|
||||
(async () => {
|
||||
try {
|
||||
if (debug) console.log('[ts-proxy] tail prefetch start', { tailRange });
|
||||
const tr = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers, Range: tailRange } });
|
||||
if (tr.status === 206) {
|
||||
const buf = Buffer.from(await tr.arrayBuffer());
|
||||
const cr = tr.headers.get('content-range');
|
||||
if (cr) {
|
||||
const mm = cr.match(/bytes\s+(\d+)-(\d+)\/(\d+)/i);
|
||||
if (mm) {
|
||||
const s = parseInt(mm[1], 10); const e = parseInt(mm[2], 10); const sz = parseInt(mm[3], 10);
|
||||
if (!isNaN(s) && !isNaN(e) && !isNaN(sz)) {
|
||||
tailPrefetchMap.set(targetUrl, { data: buf, start: s, end: e, size: sz, ts: Date.now() });
|
||||
if (debug) console.log('[ts-proxy] tail prefetch success', { start: s, end: e, bytes: buf.length });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (debug) {
|
||||
console.log('[ts-proxy] tail prefetch status', tr.status);
|
||||
}
|
||||
} catch (e) { if (debug) console.log('[ts-proxy] tail prefetch error', e.message); }
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
// If no range requested but we now know size (HEAD or probe), synthesize an initial small range fetch to accelerate playback.
|
||||
if (!force200 && !effectiveRange && contentLength && !noSynth && req.query.progressiveOpen === '0') {
|
||||
const total = parseInt(contentLength, 10);
|
||||
const desired = initChunkKB * 1024;
|
||||
const chunkSize = Math.min(desired, Math.max(0, total - 1));
|
||||
const syntheticRange = `bytes=0-${chunkSize}`;
|
||||
const resp = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers, 'Range': syntheticRange } });
|
||||
if (resp.status === 206) {
|
||||
if (debug) console.log('[ts-proxy] synthetic 206', syntheticRange);
|
||||
const upstreamCT = inferContentType(resp.headers.get('content-type'), targetUrl);
|
||||
res.status(206);
|
||||
res.setHeader('Content-Type', upstreamCT);
|
||||
res.setHeader('Accept-Ranges', upstreamAcceptRanges || 'bytes');
|
||||
const contentRange = resp.headers.get('content-range');
|
||||
if (contentRange) res.setHeader('Content-Range', contentRange);
|
||||
const cl = resp.headers.get('content-length'); if (cl) res.setHeader('Content-Length', cl);
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
// Pipe first chunk then leave connection open? Simpler: end after chunk; player will request next range.
|
||||
return resp.body.pipe(res);
|
||||
}
|
||||
// If server ignored range (e.g., returned 200), fall through to normal logic below.
|
||||
}
|
||||
|
||||
const upstreamOptions = { headers: { 'User-Agent': DEFAULT_UA, ...headers } };
|
||||
// If client supplied a suffix range like bytes=0- we pass it; force200 strips Range
|
||||
if (force200 && headers.Range) delete headers.Range;
|
||||
const resp = await fetch(targetUrl, upstreamOptions);
|
||||
if (!resp.ok && resp.status !== 206) {
|
||||
if (debug) console.log('[ts-proxy] upstream error', resp.status);
|
||||
return res.status(resp.status).json({ error: `TS fetch failed: ${resp.status}` });
|
||||
}
|
||||
// Forward partial content status when range used
|
||||
if (effectiveRange && resp.status === 206 && !force200) { res.status(206); if (debug) console.log('[ts-proxy] forwarding 206', { appliedClamp }); }
|
||||
if (force200 && resp.status === 206) {
|
||||
// Some origins still return 206 even without Range; normalize to 200 for players expecting full stream
|
||||
if (debug) console.log('[ts-proxy] normalizing 206 -> 200 due to force200');
|
||||
}
|
||||
// Copy key streaming headers
|
||||
const upstreamCT = inferContentType(resp.headers.get('content-type'), targetUrl);
|
||||
res.setHeader('Content-Type', upstreamCT);
|
||||
let cl = resp.headers.get('content-length');
|
||||
const crHeader = resp.headers.get('content-range');
|
||||
if (!cl && crHeader) {
|
||||
// Try to derive length from range span
|
||||
const m = crHeader.match(/bytes\s+(\d+)-(\d+)\/(\d+|\*)/i);
|
||||
if (m) {
|
||||
const start = parseInt(m[1], 10); const end = parseInt(m[2], 10);
|
||||
if (!isNaN(start) && !isNaN(end)) cl = (end - start + 1).toString();
|
||||
}
|
||||
}
|
||||
if (cl) res.setHeader('Content-Length', cl);
|
||||
const acceptRanges = resp.headers.get('accept-ranges');
|
||||
if (acceptRanges) res.setHeader('Accept-Ranges', acceptRanges); else res.setHeader('Accept-Ranges', 'bytes');
|
||||
const contentRange = resp.headers.get('content-range'); if (contentRange && !force200) res.setHeader('Content-Range', contentRange);
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (debug) console.log('[ts-proxy] streaming body', { status: resp.status, ct: upstreamCT, cl, contentRange: force200 ? undefined : contentRange });
|
||||
resp.body.pipe(res);
|
||||
} catch (e) { if (debug) console.log('[ts-proxy] exception', e.message); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// subtitle proxy
|
||||
app.get('/sub-proxy', cors(), async (req, res) => {
|
||||
const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'url parameter required' });
|
||||
let headers = {}; try { headers = JSON.parse(req.query.headers || '{}'); } catch {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers } });
|
||||
if (!resp.ok) return res.status(resp.status).json({ error: `subtitle fetch failed: ${resp.status}` });
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('Content-Type', resp.headers.get('content-type') || 'text/vtt');
|
||||
resp.body.pipe(res);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
}
|
||||
|
||||
function processStreamsForProxy(streams, serverUrl) {
|
||||
if (!Array.isArray(streams)) return streams;
|
||||
return streams.map(s => {
|
||||
if (!s || !s.url || typeof s.url !== 'string') return s;
|
||||
const original = extractOriginalUrl(s.url);
|
||||
const headers = s.headers || {};
|
||||
const hParam = Object.keys(headers).length ? `&headers=${encodeURIComponent(JSON.stringify(headers))}` : '';
|
||||
let host = '';
|
||||
try {
|
||||
host = new URL(original).hostname.toLowerCase();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
// Force specific hosts through ts-proxy (direct file style) even without extension
|
||||
if (host.includes('pixeldrain.') || host === 'video-downloads.googleusercontent.com') {
|
||||
return { ...s, url: `${serverUrl}/ts-proxy?url=${encodeURIComponent(original)}${hParam}` };
|
||||
}
|
||||
if (/\.(mp4|mkv)(\?|$)/i.test(original)) {
|
||||
return { ...s, url: `${serverUrl}/ts-proxy?url=${encodeURIComponent(original)}${hParam}` };
|
||||
}
|
||||
return { ...s, url: `${serverUrl}/m3u8-proxy?url=${encodeURIComponent(original)}${hParam}` };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createProxyRoutes, processStreamsForProxy };
|
||||
Reference in New Issue
Block a user