From 57b0f7ba81c6a651e7170d2d4ea181d89e3f94d3 Mon Sep 17 00:00:00 2001 From: Trevor Mears Date: Sun, 28 Dec 2025 03:21:27 -0800 Subject: [PATCH] CORS error detection expansion, binary segment handling, EPG magic byte detection --- public/js/components/VideoPlayer.js | 12 ++++- server/routes/proxy.js | 79 ++++++++++++++++++----------- server/services/epgParser.js | 14 +++-- 3 files changed, 68 insertions(+), 37 deletions(-) diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 85237ae..b150996 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -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); } diff --git a/server/routes/proxy.js b/server/routes/proxy.js index a214da9..8e57ec7 100644 --- a/server/routes/proxy.js +++ b/server/routes/proxy.js @@ -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) { diff --git a/server/services/epgParser.js b/server/services/epgParser.js index 6f1c123..c031a8f 100644 --- a/server/services/epgParser.js +++ b/server/services/epgParser.js @@ -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);