mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(live-news): add CNN & CNBC HLS streams via sidecar proxy (#682)
* feat(live-news): add CNN & CNBC HLS streams via sidecar proxy (desktop only) Add /api/hls-proxy route to sidecar that proxies HLS manifests and segments from allowlisted CDN hosts, injecting the required Referer header that browsers cannot set. Rewrites m3u8 URLs so all segments and encryption keys also route through the proxy. Desktop gets native <video> HLS playback for CNN and CNBC; web falls through to YouTube as before (no bandwidth cost on Vercel). * fix(types): add missing @types/dompurify dev dependency
This commit is contained in:
19
package-lock.json
generated
19
package-lock.json
generated
@@ -7353,18 +7353,13 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||
"integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^1.0.1",
|
||||
"domelementtype": "^2.2.0",
|
||||
"domhandler": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/maplibre-gl": "^1.13.2",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@types/topojson-specification": "^1.0.5",
|
||||
|
||||
@@ -940,6 +940,64 @@ async function dispatch(requestUrl, req, routes, context) {
|
||||
return handleLocalServiceStatus(context);
|
||||
}
|
||||
|
||||
// HLS proxy — exempt from auth because <video src="..."> cannot carry
|
||||
// custom headers. Proxies HLS manifests and segments from allowlisted CDN
|
||||
// hosts, adding the required Referer header that browsers cannot set.
|
||||
// Desktop-only (sidecar); web uses YouTube fallback.
|
||||
if (requestUrl.pathname === '/api/hls-proxy') {
|
||||
const ALLOWED_HLS_HOSTS = new Set(['cdn-ca2-na.lncnetworks.host']);
|
||||
const upstreamRaw = requestUrl.searchParams.get('url');
|
||||
if (!upstreamRaw) return new Response('Missing url param', { status: 400, headers: { 'content-type': 'text/plain', ...makeCorsHeaders(req) } });
|
||||
let upstream;
|
||||
try { upstream = new URL(upstreamRaw); } catch { return new Response('Invalid url', { status: 400, headers: { 'content-type': 'text/plain', ...makeCorsHeaders(req) } }); }
|
||||
if (upstream.protocol !== 'https:' || !ALLOWED_HLS_HOSTS.has(upstream.hostname)) {
|
||||
return new Response('Host not allowed', { status: 403, headers: { 'content-type': 'text/plain', ...makeCorsHeaders(req) } });
|
||||
}
|
||||
try {
|
||||
const hlsResp = await new Promise((resolve, reject) => {
|
||||
const reqOpts = {
|
||||
hostname: upstream.hostname,
|
||||
port: 443,
|
||||
path: upstream.pathname + upstream.search,
|
||||
method: 'GET',
|
||||
headers: { 'Referer': 'https://livenewschat.eu/', 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' },
|
||||
family: 4,
|
||||
};
|
||||
const r = https.request(reqOpts, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
|
||||
});
|
||||
r.on('error', reject);
|
||||
r.setTimeout(10000, () => r.destroy(new Error('HLS upstream timeout')));
|
||||
r.end();
|
||||
});
|
||||
if (hlsResp.status < 200 || hlsResp.status >= 300) {
|
||||
return new Response(`Upstream ${hlsResp.status}`, { status: hlsResp.status, headers: { 'content-type': 'text/plain', ...makeCorsHeaders(req) } });
|
||||
}
|
||||
const ct = hlsResp.headers['content-type'] || '';
|
||||
const isManifest = upstreamRaw.endsWith('.m3u8') || ct.includes('mpegurl') || ct.includes('x-mpegurl');
|
||||
if (isManifest) {
|
||||
const basePath = upstream.pathname.substring(0, upstream.pathname.lastIndexOf('/') + 1);
|
||||
const baseOrigin = upstream.origin;
|
||||
let manifest = hlsResp.body.toString('utf-8');
|
||||
manifest = manifest.replace(/^(?!#)(\S+)/gm, (match) => {
|
||||
const full = match.startsWith('http') ? match : `${baseOrigin}${basePath}${match}`;
|
||||
return `/api/hls-proxy?url=${encodeURIComponent(full)}`;
|
||||
});
|
||||
manifest = manifest.replace(/URI="([^"]+)"/g, (_m, uri) => {
|
||||
const full = uri.startsWith('http') ? uri : `${baseOrigin}${basePath}${uri}`;
|
||||
return `URI="/api/hls-proxy?url=${encodeURIComponent(full)}"`;
|
||||
});
|
||||
return new Response(manifest, { status: 200, headers: { 'content-type': 'application/vnd.apple.mpegurl', 'cache-control': 'no-cache', ...makeCorsHeaders(req) } });
|
||||
}
|
||||
return new Response(hlsResp.body, { status: 200, headers: { 'content-type': ct || 'application/octet-stream', 'cache-control': 'no-cache', ...makeCorsHeaders(req) } });
|
||||
} catch (e) {
|
||||
context.logger.warn('[hls-proxy] error:', e.message);
|
||||
return new Response('Proxy error', { status: 502, headers: { 'content-type': 'text/plain', ...makeCorsHeaders(req) } });
|
||||
}
|
||||
}
|
||||
|
||||
// YouTube embed bridge — exempt from auth because iframe src cannot carry
|
||||
// Authorization headers. Serves a minimal HTML page that loads the YouTube
|
||||
// IFrame Player API from a localhost origin (which YouTube accepts, unlike
|
||||
|
||||
@@ -63,6 +63,7 @@ const FULL_LIVE_CHANNELS: LiveChannel[] = [
|
||||
{ id: 'euronews', name: 'Euronews', handle: '@euronews', fallbackVideoId: 'pykpO5kQJ98' },
|
||||
{ id: 'dw', name: 'DW', handle: '@DWNews', fallbackVideoId: 'LuKwFajn37U' },
|
||||
{ id: 'cnbc', name: 'CNBC', handle: '@CNBC', fallbackVideoId: '9NyxcX3rhQs' },
|
||||
{ id: 'cnn', name: 'CNN', handle: '@CNN', fallbackVideoId: 'w_Ma8oQLmSM' },
|
||||
{ id: 'france24', name: 'France24', handle: '@France24_en', fallbackVideoId: 'Ap-UM1O9RBU' },
|
||||
{ id: 'alarabiya', name: 'AlArabiya', handle: '@AlArabiya', fallbackVideoId: 'n7eQejkXbnM', useFallbackOnly: true },
|
||||
{ id: 'aljazeera', name: 'AlJazeera', handle: '@AlJazeeraEnglish', fallbackVideoId: 'gCNeDWCI0vo', useFallbackOnly: true },
|
||||
@@ -79,6 +80,7 @@ const TECH_LIVE_CHANNELS: LiveChannel[] = [
|
||||
// Optional channels users can add from the "Available Channels" tab UI
|
||||
export const OPTIONAL_LIVE_CHANNELS: LiveChannel[] = [
|
||||
// North America
|
||||
{ id: 'cnn', name: 'CNN', handle: '@CNN', fallbackVideoId: 'w_Ma8oQLmSM' },
|
||||
{ id: 'livenow-fox', name: 'LiveNOW from FOX', handle: '@LiveNOWfromFOX', fallbackVideoId: 'QaftgYkG-ek' },
|
||||
{ id: 'fox-news', name: 'Fox News', handle: '@FoxNews', fallbackVideoId: 'QaftgYkG-ek', useFallbackOnly: true },
|
||||
{ id: 'newsmax', name: 'Newsmax', handle: '@NEWSMAX', fallbackVideoId: 'S-lFBzloL2Y', useFallbackOnly: true },
|
||||
@@ -145,7 +147,7 @@ export const OPTIONAL_LIVE_CHANNELS: LiveChannel[] = [
|
||||
];
|
||||
|
||||
export const OPTIONAL_CHANNEL_REGIONS: { key: string; labelKey: string; channelIds: string[] }[] = [
|
||||
{ key: 'na', labelKey: 'components.liveNews.regionNorthAmerica', channelIds: ['livenow-fox', 'fox-news', 'newsmax', 'abc-news', 'cbs-news', 'nbc-news', 'cbc-news'] },
|
||||
{ key: 'na', labelKey: 'components.liveNews.regionNorthAmerica', channelIds: ['cnn', 'livenow-fox', 'fox-news', 'newsmax', 'abc-news', 'cbs-news', 'nbc-news', 'cbc-news'] },
|
||||
{ key: 'eu', labelKey: 'components.liveNews.regionEurope', channelIds: ['bbc-news', 'france24-en', 'welt', 'rtve', 'trt-haber', 'ntv-turkey', 'cnn-turk', 'tv-rain', 'rt', 'tvp-info', 'telewizja-republika', 'tagesschau24', 'tv5monde-info', 'nrk1', 'aljazeera-balkans'] },
|
||||
{ key: 'latam', labelKey: 'components.liveNews.regionLatinAmerica', channelIds: ['cnn-brasil', 'jovem-pan', 'record-news', 'band-jornalismo', 'tn-argentina', 'c5n', 'milenio', 'noticias-caracol', 'ntn24', 't13'] },
|
||||
{ key: 'asia', labelKey: 'components.liveNews.regionAsia', channelIds: ['tbs-news', 'ann-news', 'ntv-news', 'cti-news', 'wion', 'ndtv', 'cna-asia', 'nhk-world', 'arirang-news', 'india-today', 'abp-news'] },
|
||||
@@ -201,6 +203,12 @@ const DIRECT_HLS_MAP: Readonly<Record<string, string>> = {
|
||||
'arirang-news': 'https://amdlive-ch01-ctnd-com.akamaized.net/arirang_1ch/smil:arirang_1ch.smil/playlist.m3u8',
|
||||
};
|
||||
|
||||
interface ProxiedHlsEntry { url: string; referer: string; }
|
||||
const PROXIED_HLS_MAP: Readonly<Record<string, ProxiedHlsEntry>> = {
|
||||
'cnbc': { url: 'https://cdn-ca2-na.lncnetworks.host/hls/cnbc_live/index.m3u8', referer: 'https://livenewschat.eu/' },
|
||||
'cnn': { url: 'https://cdn-ca2-na.lncnetworks.host/hls/cnn_live/index.m3u8', referer: 'https://livenewschat.eu/' },
|
||||
};
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
const allChannels = [...FULL_LIVE_CHANNELS, ...TECH_LIVE_CHANNELS, ...OPTIONAL_LIVE_CHANNELS];
|
||||
for (const id of Object.keys(DIRECT_HLS_MAP)) {
|
||||
@@ -387,6 +395,15 @@ export class LiveNewsPanel extends Panel {
|
||||
return url;
|
||||
}
|
||||
|
||||
private getProxiedHlsUrl(channelId: string): string | undefined {
|
||||
if (!isDesktopRuntime()) return undefined;
|
||||
const entry = PROXIED_HLS_MAP[channelId];
|
||||
if (!entry) return undefined;
|
||||
const failedAt = this.hlsFailureCooldown.get(channelId);
|
||||
if (failedAt && Date.now() - failedAt < this.HLS_COOLDOWN_MS) return undefined;
|
||||
return `http://127.0.0.1:${getLocalApiPort()}/api/hls-proxy?url=${encodeURIComponent(entry.url)}`;
|
||||
}
|
||||
|
||||
private get embedOrigin(): string {
|
||||
if (isDesktopRuntime()) return `http://localhost:${getLocalApiPort()}`;
|
||||
try { return new URL(getRemoteApiBaseUrl()).origin; } catch { return 'https://worldmonitor.app'; }
|
||||
@@ -774,7 +791,7 @@ export class LiveNewsPanel extends Panel {
|
||||
private async resolveChannelVideo(channel: LiveChannel, forceFallback = false): Promise<void> {
|
||||
const useFallbackVideo = channel.useFallbackOnly || forceFallback;
|
||||
|
||||
if (isDesktopRuntime() && this.getDirectHlsUrl(channel.id)) {
|
||||
if (isDesktopRuntime() && (this.getDirectHlsUrl(channel.id) || this.getProxiedHlsUrl(channel.id))) {
|
||||
channel.videoId = channel.fallbackVideoId;
|
||||
channel.isLive = true;
|
||||
return;
|
||||
@@ -815,7 +832,7 @@ export class LiveNewsPanel extends Panel {
|
||||
}
|
||||
});
|
||||
|
||||
if (this.getDirectHlsUrl(channel.id)) {
|
||||
if (this.getDirectHlsUrl(channel.id) || this.getProxiedHlsUrl(channel.id)) {
|
||||
this.renderNativeHlsPlayer();
|
||||
return;
|
||||
}
|
||||
@@ -964,8 +981,8 @@ export class LiveNewsPanel extends Panel {
|
||||
}
|
||||
|
||||
private renderNativeHlsPlayer(): void {
|
||||
const hlsUrl = this.getDirectHlsUrl(this.activeChannel.id);
|
||||
if (!hlsUrl || !hlsUrl.startsWith('https://')) return;
|
||||
const hlsUrl = this.getDirectHlsUrl(this.activeChannel.id) || this.getProxiedHlsUrl(this.activeChannel.id);
|
||||
if (!hlsUrl || !(hlsUrl.startsWith('https://') || hlsUrl.startsWith('http://127.0.0.1'))) return;
|
||||
|
||||
this.destroyPlayer();
|
||||
this.ensurePlayerContainer();
|
||||
@@ -1105,7 +1122,7 @@ export class LiveNewsPanel extends Panel {
|
||||
this.forceFallbackVideoForNextInit = false;
|
||||
await this.resolveChannelVideo(this.activeChannel, useFallbackVideo);
|
||||
|
||||
if (this.getDirectHlsUrl(this.activeChannel.id)) {
|
||||
if (this.getDirectHlsUrl(this.activeChannel.id) || this.getProxiedHlsUrl(this.activeChannel.id)) {
|
||||
this.renderNativeHlsPlayer();
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user