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)
|
||||
|
||||
138
server/services/proxyStream.js
Normal file
138
server/services/proxyStream.js
Normal file
@ -0,0 +1,138 @@
|
||||
const { Readable } = require('node:stream');
|
||||
const { pipeline } = require('node:stream/promises');
|
||||
|
||||
const MAX_HLS_MANIFEST_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
async function* prepend(first, iterator) {
|
||||
try {
|
||||
yield first;
|
||||
for (let next = await iterator.next(); !next.done; next = await iterator.next()) {
|
||||
yield Buffer.from(next.value);
|
||||
}
|
||||
} finally {
|
||||
try { await iterator.return?.(); } catch { /* Upstream may already be aborted. */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function collectManifest(first, iterator) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of prepend(first, iterator)) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_HLS_MANIFEST_BYTES) {
|
||||
const error = new Error('HLS manifest exceeds the proxy size limit');
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks, size).toString('utf8');
|
||||
}
|
||||
|
||||
function rewriteManifest(manifest, sourceUrl, proxyPath) {
|
||||
const proxied = value => `${proxyPath}?url=${encodeURIComponent(new URL(value, sourceUrl).href)}`;
|
||||
return manifest.split('\n').map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return line;
|
||||
if (trimmed.startsWith('#')) {
|
||||
return line.replace(/URI=["']([^"']+)["']/g, (match, value) => {
|
||||
try { return `URI="${proxied(value)}"`; } catch { return match; }
|
||||
});
|
||||
}
|
||||
try { return proxied(trimmed); } catch { return line; }
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
// Adapted from NodeCast TV Plus's streaming proxy. Binary media is never
|
||||
// buffered in full; only manifests are collected, under a fixed size limit.
|
||||
async function proxyStream(req, res) {
|
||||
let url;
|
||||
try {
|
||||
const raw = req.query.url;
|
||||
if (typeof raw !== 'string' || /[\x00-\x1f\x7f]/.test(raw)) throw new Error();
|
||||
const parsed = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error();
|
||||
url = parsed.href;
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'A valid HTTP(S) URL is required' });
|
||||
}
|
||||
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
if (res.destroyed) return;
|
||||
const controller = new AbortController();
|
||||
const abort = () => { if (!res.writableEnded) controller.abort(); };
|
||||
res.once('close', abort);
|
||||
try {
|
||||
const origin = new URL(url).origin;
|
||||
const pluto = ['pluto.tv', 'pluto.io', 'plutotv.net', 'service-stitcher'].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', 'Accept-Encoding': 'identity',
|
||||
Origin: pluto ? 'https://pluto.tv' : origin,
|
||||
Referer: pluto ? 'https://pluto.tv/' : `${origin}/`
|
||||
};
|
||||
const range = req.get('range');
|
||||
if (range) headers.Range = range;
|
||||
const upstream = await fetch(url, { headers, signal: controller.signal });
|
||||
if (upstream.status >= 500 && attempt < 2) {
|
||||
await upstream.body?.cancel();
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
continue;
|
||||
}
|
||||
if (!upstream.ok) return res.status(upstream.status).send(`Failed to fetch stream: ${upstream.statusText}`);
|
||||
|
||||
const type = upstream.headers.get('content-type') || 'application/octet-stream';
|
||||
const length = upstream.headers.get('content-length');
|
||||
const encoding = upstream.headers.get('content-encoding');
|
||||
const contentRange = upstream.headers.get('content-range');
|
||||
const accepts = upstream.headers.get('accept-ranges');
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
res.status(upstream.status);
|
||||
if (length && (!encoding || encoding === 'identity')) res.set('Content-Length', length);
|
||||
if (contentRange) res.set('Content-Range', contentRange);
|
||||
if (range && !(upstream.status === 206 && contentRange)) res.set('Accept-Ranges', 'none');
|
||||
else if (accepts) res.set('Accept-Ranges', accepts);
|
||||
|
||||
if (!upstream.body) return res.end();
|
||||
const iterator = upstream.body[Symbol.asyncIterator]();
|
||||
const prefix = [];
|
||||
let size = 0;
|
||||
// The HLS signature can be split across network chunks.
|
||||
while (size < 7) {
|
||||
const next = await iterator.next();
|
||||
if (next.done) break;
|
||||
const chunk = Buffer.from(next.value);
|
||||
prefix.push(chunk);
|
||||
size += chunk.length;
|
||||
}
|
||||
const first = Buffer.concat(prefix, size);
|
||||
const isManifest = first.subarray(0, 7).toString() === '#EXTM3U'
|
||||
|| /(?:mpegurl|m3u8)/i.test(type);
|
||||
if (isManifest) {
|
||||
const manifest = await collectManifest(first, iterator);
|
||||
res.removeHeader('Content-Length');
|
||||
res.removeHeader('Content-Range');
|
||||
res.set('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
return res.send(rewriteManifest(manifest, upstream.url || url, `${req.baseUrl}/stream`));
|
||||
}
|
||||
res.set('Content-Type', type);
|
||||
await pipeline(Readable.from(prepend(first, iterator), { objectMode: false }), res);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (res.destroyed || controller.signal.aborted) return;
|
||||
lastError = error;
|
||||
if (res.headersSent) { res.destroy(error); return; }
|
||||
res.removeHeader('Content-Length');
|
||||
res.removeHeader('Content-Range');
|
||||
if (error.statusCode) return res.status(error.statusCode).json({ error: error.message });
|
||||
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 500));
|
||||
} finally {
|
||||
res.off('close', abort);
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
if (!res.headersSent && !res.destroyed) res.status(502).json({ error: lastError?.message || 'Stream proxy failed' });
|
||||
}
|
||||
|
||||
module.exports = proxyStream;
|
||||
Reference in New Issue
Block a user