CORS error detection expansion, binary segment handling, EPG magic byte detection

This commit is contained in:
Trevor Mears
2025-12-28 03:21:27 -08:00
parent ebb3491005
commit 57b0f7ba81
3 changed files with 68 additions and 37 deletions
+10 -2
View File
@@ -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
View File
@@ -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) {
+9 -5
View File
@@ -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);