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;
|
this.currentUrl = streamUrl;
|
||||||
|
|
||||||
// Proactively use proxy for known CORS-restricted domains (like Pluto TV)
|
// 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 proxyRequiredDomains = ['pluto.tv'];
|
||||||
const needsProxy = proxyRequiredDomains.some(domain => streamUrl.includes(domain));
|
const needsProxy = proxyRequiredDomains.some(domain => streamUrl.includes(domain));
|
||||||
|
|
||||||
@@ -221,11 +222,18 @@ class VideoPlayer {
|
|||||||
// Re-attach error handler for the new Hls instance
|
// Re-attach error handler for the new Hls instance
|
||||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && !this.isUsingProxy) {
|
// CORS issues can manifest as NETWORK_ERROR or MEDIA_ERROR with fragParsingError
|
||||||
console.log('CORS/Network error detected, retrying via proxy...');
|
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.isUsingProxy = true;
|
||||||
this.hls.loadSource(this.getProxiedUrl(this.currentUrl));
|
this.hls.loadSource(this.getProxiedUrl(this.currentUrl));
|
||||||
this.hls.startLoad();
|
this.hls.startLoad();
|
||||||
|
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||||
|
console.log('Media error, attempting recovery...');
|
||||||
|
this.hls.recoverMediaError();
|
||||||
} else {
|
} else {
|
||||||
console.error('Fatal HLS error:', data);
|
console.error('Fatal HLS error:', data);
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-30
@@ -276,54 +276,73 @@ router.get('/stream', async (req, res) => {
|
|||||||
|
|
||||||
const response = await fetch(url, { headers });
|
const response = await fetch(url, { headers });
|
||||||
if (!response.ok) {
|
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}`);
|
return res.status(response.status).send(`Failed to fetch stream: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
res.set('Access-Control-Allow-Origin', '*');
|
res.set('Access-Control-Allow-Origin', '*');
|
||||||
|
|
||||||
// Check if it's an HLS manifest
|
// Read response as array buffer
|
||||||
const isHls = contentType.includes('mpegurl') || contentType.includes('application/x-mpegURL') || url.toLowerCase().includes('.m3u8');
|
const buffer = await response.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
|
||||||
if (isHls) {
|
// Check if it's an HLS manifest by looking at first bytes (#EXTM3U = 23 45 58 54 4D 33 55)
|
||||||
let manifest = await response.text();
|
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
|
// Rewrite URLs inside manifest
|
||||||
if (manifest.trim().startsWith('#EXTM3U')) {
|
|
||||||
res.set('Content-Type', 'application/vnd.apple.mpegurl');
|
|
||||||
|
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const baseUrl = urlObj.origin + urlObj.pathname.substring(0, urlObj.pathname.lastIndexOf('/') + 1);
|
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 => {
|
manifest = manifest.split('\n').map(line => {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (trimmed === '' || trimmed.startsWith('#')) {
|
if (trimmed === '' || trimmed.startsWith('#')) {
|
||||||
if (trimmed.includes('URI="')) {
|
if (trimmed.includes('URI="')) {
|
||||||
return line.replace(/URI="([^"]+)"/g, (match, p1) => {
|
return line.replace(/URI="([^"]+)"/g, (match, p1) => {
|
||||||
try {
|
try {
|
||||||
const absoluteUrl = new URL(p1, baseUrl).href;
|
const absoluteUrl = new URL(p1, baseUrl).href;
|
||||||
return `URI="${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}"`;
|
return `URI="${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}"`;
|
||||||
} catch (e) { return match; }
|
} catch (e) { return match; }
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return line;
|
|
||||||
}
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// Check if it's a URL (segment or playlist reference)
|
||||||
const absoluteUrl = new URL(trimmed, baseUrl).href;
|
try {
|
||||||
return `${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}`;
|
// Handle both relative and absolute URLs
|
||||||
} catch (e) { return line; }
|
let absoluteUrl;
|
||||||
}).join('\n');
|
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);
|
return res.send(manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binary content (segments)
|
// Binary content (segments)
|
||||||
res.set('Content-Type', contentType);
|
res.set('Content-Type', contentType || 'application/octet-stream');
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
return res.send(Buffer.from(buffer));
|
return res.send(Buffer.from(buffer));
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -168,12 +168,15 @@ async function fetchAndParse(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
const contentEncoding = response.headers.get('content-encoding');
|
const buffer = await response.arrayBuffer();
|
||||||
const isGzipped = url.endsWith('.gz') || contentEncoding === 'gzip';
|
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)
|
// Handle gzipped EPG files (.xml.gz)
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
try {
|
try {
|
||||||
const decompressed = await gunzip(Buffer.from(buffer));
|
const decompressed = await gunzip(Buffer.from(buffer));
|
||||||
content = decompressed.toString('utf-8');
|
content = decompressed.toString('utf-8');
|
||||||
@@ -181,7 +184,8 @@ async function fetchAndParse(url) {
|
|||||||
throw new Error(`Failed to decompress gzipped EPG: ${err.message}`);
|
throw new Error(`Failed to decompress gzipped EPG: ${err.message}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
content = await response.text();
|
// Already plain text (or auto-decompressed by fetch)
|
||||||
|
content = Buffer.from(buffer).toString('utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
return parse(content);
|
return parse(content);
|
||||||
|
|||||||
Reference in New Issue
Block a user