CORS error detection expansion, binary segment handling, EPG magic byte detection
This commit is contained in:
@@ -202,6 +202,7 @@ class VideoPlayer {
|
||||
this.currentUrl = streamUrl;
|
||||
|
||||
// Proactively use proxy for known CORS-restricted domains (like Pluto TV)
|
||||
// Note: Xtream sources are NOT auto-proxied because many providers IP-lock streams
|
||||
const proxyRequiredDomains = ['pluto.tv'];
|
||||
const needsProxy = proxyRequiredDomains.some(domain => streamUrl.includes(domain));
|
||||
|
||||
@@ -221,11 +222,18 @@ class VideoPlayer {
|
||||
// Re-attach error handler for the new Hls instance
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && !this.isUsingProxy) {
|
||||
console.log('CORS/Network error detected, retrying via proxy...');
|
||||
// CORS issues can manifest as NETWORK_ERROR or MEDIA_ERROR with fragParsingError
|
||||
const isCorsLikely = data.type === Hls.ErrorTypes.NETWORK_ERROR ||
|
||||
(data.type === Hls.ErrorTypes.MEDIA_ERROR && data.details === 'fragParsingError');
|
||||
|
||||
if (isCorsLikely && !this.isUsingProxy) {
|
||||
console.log('CORS/Network error detected, retrying via proxy...', data.details);
|
||||
this.isUsingProxy = true;
|
||||
this.hls.loadSource(this.getProxiedUrl(this.currentUrl));
|
||||
this.hls.startLoad();
|
||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
console.log('Media error, attempting recovery...');
|
||||
this.hls.recoverMediaError();
|
||||
} else {
|
||||
console.error('Fatal HLS error:', data);
|
||||
}
|
||||
|
||||
+49
-30
@@ -276,54 +276,73 @@ router.get('/stream', async (req, res) => {
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
if (!response.ok) {
|
||||
console.error(`Upstream error for ${url}: ${response.status} ${response.statusText}`);
|
||||
console.error(`Upstream error for ${url.substring(0, 80)}...: ${response.status} ${response.statusText}`);
|
||||
// Log response body for debugging 403s
|
||||
if (response.status === 403) {
|
||||
const errorBody = await response.text().catch(() => 'N/A');
|
||||
console.error(`403 Response body: ${errorBody.substring(0, 200)}`);
|
||||
}
|
||||
return res.status(response.status).send(`Failed to fetch stream: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// Check if it's an HLS manifest
|
||||
const isHls = contentType.includes('mpegurl') || contentType.includes('application/x-mpegURL') || url.toLowerCase().includes('.m3u8');
|
||||
// Read response as array buffer
|
||||
const buffer = await response.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (isHls) {
|
||||
let manifest = await response.text();
|
||||
// Check if it's an HLS manifest by looking at first bytes (#EXTM3U = 23 45 58 54 4D 33 55)
|
||||
const textPrefix = String.fromCharCode(...bytes.slice(0, 7));
|
||||
const contentLooksLikeHls = textPrefix === '#EXTM3U';
|
||||
const urlLooksLikeHls = contentType.includes('mpegurl') || contentType.includes('application/x-mpegURL') || url.toLowerCase().includes('.m3u8');
|
||||
|
||||
if (contentLooksLikeHls) {
|
||||
console.log(`[Proxy] Processing HLS manifest from: ${url.substring(0, 80)}...`);
|
||||
res.set('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
|
||||
let manifest = Buffer.from(buffer).toString('utf-8');
|
||||
// Rewrite URLs inside manifest
|
||||
if (manifest.trim().startsWith('#EXTM3U')) {
|
||||
res.set('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
|
||||
const urlObj = new URL(url);
|
||||
const baseUrl = urlObj.origin + urlObj.pathname.substring(0, urlObj.pathname.lastIndexOf('/') + 1);
|
||||
const urlObj = new URL(url);
|
||||
const baseUrl = urlObj.origin + urlObj.pathname.substring(0, urlObj.pathname.lastIndexOf('/') + 1);
|
||||
console.log(`[Proxy] Base URL for rewriting: ${baseUrl}`);
|
||||
|
||||
manifest = manifest.split('\n').map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '' || trimmed.startsWith('#')) {
|
||||
if (trimmed.includes('URI="')) {
|
||||
return line.replace(/URI="([^"]+)"/g, (match, p1) => {
|
||||
try {
|
||||
const absoluteUrl = new URL(p1, baseUrl).href;
|
||||
return `URI="${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}"`;
|
||||
} catch (e) { return match; }
|
||||
});
|
||||
}
|
||||
return line;
|
||||
manifest = manifest.split('\n').map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '' || trimmed.startsWith('#')) {
|
||||
if (trimmed.includes('URI="')) {
|
||||
return line.replace(/URI="([^"]+)"/g, (match, p1) => {
|
||||
try {
|
||||
const absoluteUrl = new URL(p1, baseUrl).href;
|
||||
return `URI="${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}"`;
|
||||
} catch (e) { return match; }
|
||||
});
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
try {
|
||||
const absoluteUrl = new URL(trimmed, baseUrl).href;
|
||||
return `${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}`;
|
||||
} catch (e) { return line; }
|
||||
}).join('\n');
|
||||
}
|
||||
// Check if it's a URL (segment or playlist reference)
|
||||
try {
|
||||
// Handle both relative and absolute URLs
|
||||
let absoluteUrl;
|
||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||
// Already an absolute URL
|
||||
absoluteUrl = trimmed;
|
||||
} else {
|
||||
// Relative URL - make it absolute
|
||||
absoluteUrl = new URL(trimmed, baseUrl).href;
|
||||
}
|
||||
return `${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}`;
|
||||
} catch (e) { return line; }
|
||||
}).join('\n');
|
||||
|
||||
// Return manifest (whether rewritten or not)
|
||||
// Return rewritten manifest
|
||||
return res.send(manifest);
|
||||
}
|
||||
|
||||
// Binary content (segments)
|
||||
res.set('Content-Type', contentType);
|
||||
const buffer = await response.arrayBuffer();
|
||||
res.set('Content-Type', contentType || 'application/octet-stream');
|
||||
return res.send(Buffer.from(buffer));
|
||||
|
||||
} catch (err) {
|
||||
|
||||
@@ -168,12 +168,15 @@ async function fetchAndParse(url) {
|
||||
}
|
||||
|
||||
let content;
|
||||
const contentEncoding = response.headers.get('content-encoding');
|
||||
const isGzipped = url.endsWith('.gz') || contentEncoding === 'gzip';
|
||||
const buffer = await response.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (isGzipped) {
|
||||
// Check for gzip magic bytes (1f 8b) to detect actual gzip content
|
||||
// This handles cases where the server auto-decompresses or the URL doesn't reflect actual encoding
|
||||
const isActuallyGzipped = bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
|
||||
|
||||
if (isActuallyGzipped) {
|
||||
// Handle gzipped EPG files (.xml.gz)
|
||||
const buffer = await response.arrayBuffer();
|
||||
try {
|
||||
const decompressed = await gunzip(Buffer.from(buffer));
|
||||
content = decompressed.toString('utf-8');
|
||||
@@ -181,7 +184,8 @@ async function fetchAndParse(url) {
|
||||
throw new Error(`Failed to decompress gzipped EPG: ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
content = await response.text();
|
||||
// Already plain text (or auto-decompressed by fetch)
|
||||
content = Buffer.from(buffer).toString('utf-8');
|
||||
}
|
||||
|
||||
return parse(content);
|
||||
|
||||
Reference in New Issue
Block a user