fix(proxy): stream binary media with cancellation and bounded HLS buffers
Adapted from MikaelKW/nodecast-tv-plus commit 011b634417b2ecd2a2844df9808b42de831ba194. Use awaited pipeline, abort upstream on disconnect, avoid retries after headers, preserve real range support, and rewrite bounded manifests using relative proxy paths. Cover interrupted streams, first-byte delivery, ranges, redirects, split signatures, and invalid inputs. Co-authored-by: Mikael <mikaelkwessel@gmail.com>
This commit is contained in:
@ -597,184 +597,7 @@ router.post('/epg/:sourceId/channels', async (req, res) => {
|
||||
* This handles CORS for streams that don't allow cross-origin
|
||||
* Supports HTTP Range requests for video seeking
|
||||
*/
|
||||
router.get('/stream', async (req, res) => {
|
||||
const maxRetries = 2;
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
let { url } = req.query;
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL required' });
|
||||
}
|
||||
|
||||
// Forward some headers to be more "transparent" back to the origin
|
||||
// Pluto TV uses multiple domains for content delivery
|
||||
const plutoDomains = ['pluto.tv', 'pluto.io', 'plutotv.net', 'siloh.pluto.tv', 'service-stitcher'];
|
||||
const isPluto = plutoDomains.some(domain => url.includes(domain));
|
||||
|
||||
const headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
// Using https and matching the origin of the request
|
||||
'Origin': isPluto ? 'https://pluto.tv' : new URL(url).origin,
|
||||
'Referer': isPluto ? 'https://pluto.tv/' : new URL(url).origin + '/'
|
||||
};
|
||||
|
||||
// Forward Range header for video seeking support
|
||||
const rangeHeader = req.get('range');
|
||||
if (rangeHeader) {
|
||||
headers['Range'] = rangeHeader;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
// Retry on 5xx errors (transient upstream issues)
|
||||
if (response.status >= 500 && attempt < maxRetries) {
|
||||
console.log(`[Proxy] Upstream 5xx error (attempt ${attempt}/${maxRetries}), retrying in 500ms...`);
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Upstream error for ${url.substring(0, 80)}...: ${response.status} ${response.statusText}`);
|
||||
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', '*');
|
||||
|
||||
// Forward range-related headers for video seeking support
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const contentRange = response.headers.get('content-range');
|
||||
const acceptRanges = response.headers.get('accept-ranges');
|
||||
|
||||
if (contentLength) {
|
||||
res.set('Content-Length', contentLength);
|
||||
}
|
||||
if (contentRange) {
|
||||
res.set('Content-Range', contentRange);
|
||||
}
|
||||
if (acceptRanges) {
|
||||
res.set('Accept-Ranges', acceptRanges);
|
||||
} else if (contentLength && !contentRange) {
|
||||
// If server supports content-length but didn't explicitly state accept-ranges,
|
||||
// we can safely assume it supports byte ranges
|
||||
res.set('Accept-Ranges', 'bytes');
|
||||
}
|
||||
|
||||
// Set status code (206 for partial content when range request was made)
|
||||
res.status(response.status);
|
||||
|
||||
// Create an async iterator for the response body
|
||||
const iterator = response.body[Symbol.asyncIterator]();
|
||||
const first = await iterator.next();
|
||||
|
||||
if (first.done) {
|
||||
res.set('Content-Type', contentType || 'application/octet-stream');
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const firstChunk = Buffer.from(first.value);
|
||||
|
||||
// Peek at first bytes to check for HLS manifest ({ #EXTM3U })
|
||||
const textPrefix = firstChunk.subarray(0, 7).toString('utf8');
|
||||
const contentLooksLikeHls = textPrefix === '#EXTM3U';
|
||||
|
||||
if (contentLooksLikeHls) {
|
||||
// HLS Manifest: We must read the WHOLE manifest to rewrite it
|
||||
const chunks = [firstChunk];
|
||||
|
||||
// Consume the rest of the stream
|
||||
let result = await iterator.next();
|
||||
while (!result.done) {
|
||||
chunks.push(Buffer.from(result.value));
|
||||
result = await iterator.next();
|
||||
}
|
||||
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const finalUrl = response.url || url;
|
||||
console.log(`[Proxy] Processing HLS manifest from: ${finalUrl.substring(0, 80)}...`);
|
||||
res.set('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
|
||||
let manifest = buffer.toString('utf-8');
|
||||
|
||||
const finalUrlObj = new URL(finalUrl);
|
||||
const baseUrl = finalUrlObj.origin + finalUrlObj.pathname.substring(0, finalUrlObj.pathname.lastIndexOf('/') + 1);
|
||||
|
||||
manifest = manifest.split('\n').map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '' || trimmed.startsWith('#')) {
|
||||
// Handle both URI="..." and URI='...' formats
|
||||
if (trimmed.includes('URI=')) {
|
||||
// Replace both double and single quoted URIs
|
||||
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;
|
||||
}
|
||||
|
||||
// Stream URL handling
|
||||
try {
|
||||
let absoluteUrl;
|
||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||
absoluteUrl = trimmed;
|
||||
} else {
|
||||
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 res.send(manifest);
|
||||
}
|
||||
|
||||
// Binary content (Video Segment or Key): Collect and send
|
||||
console.log(`[Proxy] Serving binary content (${contentType})`);
|
||||
res.set('Content-Type', contentType || 'application/octet-stream');
|
||||
|
||||
// For small files (like encryption keys), collect all data and send at once
|
||||
// This ensures proper Content-Length and response completion
|
||||
const chunks = [firstChunk];
|
||||
let result = await iterator.next();
|
||||
while (!result.done) {
|
||||
chunks.push(Buffer.from(result.value));
|
||||
result = await iterator.next();
|
||||
}
|
||||
const fullContent = Buffer.concat(chunks);
|
||||
|
||||
// Set Content-Length for proper client handling
|
||||
res.set('Content-Length', fullContent.length);
|
||||
res.send(fullContent);
|
||||
return; // Success - exit the retry loop
|
||||
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
console.error(`Stream proxy error (attempt ${attempt}/${maxRetries}):`, err.message);
|
||||
if (attempt < maxRetries) {
|
||||
console.log('[Proxy] Retrying after error...');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: lastError?.message || 'Stream proxy failed after retries' });
|
||||
}
|
||||
});
|
||||
router.get('/stream', require('../services/proxyStream'));
|
||||
|
||||
/**
|
||||
* Proxy images (channel logos, posters)
|
||||
|
||||
Reference in New Issue
Block a user