Lots of attempts at optimization
This commit is contained in:
16
Dockerfile
16
Dockerfile
@@ -1,12 +1,24 @@
|
||||
FROM ghcr.io/mwood77/ws4kp-international:latest
|
||||
|
||||
# Install FFmpeg, Chromium, wget, and unzip
|
||||
# Install FFmpeg, Chromium, wget, unzip, and canvas dependencies
|
||||
RUN apk add --no-cache \
|
||||
chromium \
|
||||
ffmpeg \
|
||||
font-noto-emoji \
|
||||
wget \
|
||||
unzip
|
||||
unzip \
|
||||
cairo-dev \
|
||||
jpeg-dev \
|
||||
pango-dev \
|
||||
giflib-dev \
|
||||
pixman-dev \
|
||||
pangomm-dev \
|
||||
libjpeg-turbo-dev \
|
||||
freetype-dev \
|
||||
build-base \
|
||||
g++ \
|
||||
make \
|
||||
python3
|
||||
|
||||
# Download and extract Weatherscan music
|
||||
RUN mkdir -p /music && \
|
||||
|
||||
604
index.js
604
index.js
@@ -92,7 +92,7 @@ function shuffleArray(array) {
|
||||
}
|
||||
|
||||
// Main streaming handler
|
||||
async function streamHandler(req, res, useMusic = false) {
|
||||
async function streamHandler(req, res, useMusic = false, lateGeocodePromise = null) {
|
||||
const { url, width = 1920, height = 1080, fps = 30, hideLogo = 'false' } = req.query;
|
||||
|
||||
if (!url) {
|
||||
@@ -111,108 +111,74 @@ async function streamHandler(req, res, useMusic = false) {
|
||||
let isCleaningUp = false;
|
||||
|
||||
try {
|
||||
console.log(`Starting stream for: ${url}`);
|
||||
|
||||
// Set HLS headers
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// Launch browser
|
||||
browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
`--window-size=${width},${height}`
|
||||
],
|
||||
defaultViewport: { width: parseInt(width), height: parseInt(height) }
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: parseInt(width), height: parseInt(height) });
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
|
||||
// Hide logo if requested
|
||||
if (hideLogo === 'true') {
|
||||
await page.evaluate(() => {
|
||||
const images = document.querySelectorAll('img');
|
||||
images.forEach(img => {
|
||||
if (img.src && img.src.includes('Logo3.png')) {
|
||||
img.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
console.log('Logo hidden');
|
||||
}
|
||||
|
||||
console.log('Page loaded, starting FFmpeg...');
|
||||
|
||||
// Build FFmpeg command with optional music
|
||||
// Build FFmpeg command and playlist in parallel with browser launch
|
||||
const ffmpegArgs = [];
|
||||
let playlistFile = null;
|
||||
|
||||
if (useMusic) {
|
||||
// Get all music files and shuffle them
|
||||
const allMusicFiles = getAllMusicFiles();
|
||||
if (allMusicFiles.length > 0) {
|
||||
const shuffledFiles = shuffleArray([...allMusicFiles]);
|
||||
const prepareFFmpegPromise = (async () => {
|
||||
if (useMusic) {
|
||||
// Get all music files and shuffle them
|
||||
const allMusicFiles = getAllMusicFiles();
|
||||
if (allMusicFiles.length > 0 && allMusicFiles.length > 0) {
|
||||
// Create a temporary concat playlist file
|
||||
playlistFile = path.join('/tmp', `playlist-${Date.now()}.txt`);
|
||||
|
||||
// Create a temporary concat playlist file
|
||||
playlistFile = path.join('/tmp', `playlist-${Date.now()}.txt`);
|
||||
|
||||
// Build playlist content - repeat the shuffled list multiple times to ensure we don't run out
|
||||
let playlistContent = '';
|
||||
for (let repeat = 0; repeat < 100; repeat++) {
|
||||
// Re-shuffle for each repeat to keep it truly random
|
||||
// Build playlist content - just 1 repetition since we loop infinitely with -stream_loop
|
||||
const currentShuffle = shuffleArray([...allMusicFiles]);
|
||||
currentShuffle.forEach(file => {
|
||||
playlistContent += `file '${file}'\n`;
|
||||
});
|
||||
const playlistLines = currentShuffle.map(f => `file '${f}'`);
|
||||
|
||||
fs.writeFileSync(playlistFile, playlistLines.join('\n'));
|
||||
console.log(`Created shuffled playlist with ${allMusicFiles.length} tracks (infinite loop)`);
|
||||
|
||||
// Input 0: video frames
|
||||
ffmpegArgs.push(
|
||||
'-use_wallclock_as_timestamps', '1',
|
||||
'-f', 'image2pipe',
|
||||
'-framerate', fps.toString(),
|
||||
'-i', 'pipe:0'
|
||||
);
|
||||
// Input 1: audio from concat playlist
|
||||
ffmpegArgs.push(
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-stream_loop', '-1', // Loop playlist infinitely
|
||||
'-probesize', '32', // Minimal probing for faster startup
|
||||
'-analyzeduration', '0', // Skip analysis for faster startup
|
||||
'-i', playlistFile
|
||||
);
|
||||
// Encoding
|
||||
ffmpegArgs.push(
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'ultrafast',
|
||||
'-tune', 'zerolatency',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-g', fps.toString(), // Keyframe every second for 1s segments
|
||||
'-bf', '0', // No B-frames for lower latency
|
||||
'-x264opts', 'nal-hrd=cbr:no-scenecut', // Constant bitrate, no scene detection
|
||||
'-b:v', '2500k', // Target bitrate for stable encoding
|
||||
'-maxrate', '2500k',
|
||||
'-bufsize', '5000k',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-ar', '44100', // Set explicit audio sample rate
|
||||
'-f', 'hls',
|
||||
'-hls_time', '1', // Smaller segments for faster startup
|
||||
'-hls_list_size', '3', // Fewer segments in playlist
|
||||
'-hls_flags', 'delete_segments+omit_endlist',
|
||||
'-hls_start_number_source', 'epoch',
|
||||
'-start_number', '0', // Start from segment 0
|
||||
'-flush_packets', '1', // Flush packets immediately
|
||||
'pipe:1'
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
fs.writeFileSync(playlistFile, playlistContent);
|
||||
console.log(`Created shuffled playlist with ${allMusicFiles.length} unique tracks`);
|
||||
|
||||
// Input 0: video frames
|
||||
ffmpegArgs.push(
|
||||
'-use_wallclock_as_timestamps', '1',
|
||||
'-f', 'image2pipe',
|
||||
'-framerate', fps.toString(),
|
||||
'-i', 'pipe:0'
|
||||
);
|
||||
// Input 1: audio from concat playlist
|
||||
ffmpegArgs.push(
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-i', playlistFile
|
||||
);
|
||||
// Encoding
|
||||
ffmpegArgs.push(
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'ultrafast',
|
||||
'-tune', 'zerolatency',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-g', (fps * 2).toString(),
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-shortest',
|
||||
'-f', 'hls',
|
||||
'-hls_time', '2',
|
||||
'-hls_list_size', '5',
|
||||
'-hls_flags', 'delete_segments',
|
||||
'pipe:1'
|
||||
);
|
||||
} else {
|
||||
console.warn('No music files found, streaming without audio');
|
||||
useMusic = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!useMusic) {
|
||||
// Video only (no music)
|
||||
ffmpegArgs.push(
|
||||
'-use_wallclock_as_timestamps', '1',
|
||||
@@ -223,19 +189,60 @@ async function streamHandler(req, res, useMusic = false) {
|
||||
'-preset', 'ultrafast',
|
||||
'-tune', 'zerolatency',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-g', (fps * 2).toString(),
|
||||
'-g', fps.toString(), // Keyframe every second for 1s segments
|
||||
'-bf', '0',
|
||||
'-x264opts', 'nal-hrd=cbr:no-scenecut',
|
||||
'-b:v', '2500k',
|
||||
'-maxrate', '2500k',
|
||||
'-bufsize', '5000k',
|
||||
'-f', 'hls',
|
||||
'-hls_time', '2',
|
||||
'-hls_list_size', '5',
|
||||
'-hls_flags', 'delete_segments',
|
||||
'-hls_time', '1', // Smaller segments for faster startup
|
||||
'-hls_list_size', '3', // Fewer segments in playlist
|
||||
'-hls_flags', 'delete_segments+omit_endlist',
|
||||
'-hls_start_number_source', 'epoch',
|
||||
'-start_number', '0',
|
||||
'-flush_packets', '1', // Flush packets immediately
|
||||
'pipe:1'
|
||||
);
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
// Start FFmpeg
|
||||
ffmpegProcess = spawn('ffmpeg', ffmpegArgs);
|
||||
// Launch browser in parallel with FFmpeg preparation
|
||||
const browserPromise = puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--disable-extensions',
|
||||
'--disable-default-apps',
|
||||
'--disable-sync',
|
||||
'--disable-translate',
|
||||
'--disable-background-networking',
|
||||
'--disable-background-timer-throttling',
|
||||
'--no-first-run',
|
||||
'--mute-audio',
|
||||
'--disable-breakpad',
|
||||
'--disable-component-update',
|
||||
`--window-size=${width},${height}`,
|
||||
`--force-device-scale-factor=1` // Ensure no DPI scaling issues
|
||||
],
|
||||
defaultViewport: { width: parseInt(width), height: parseInt(height), deviceScaleFactor: 1 }
|
||||
});
|
||||
|
||||
// Pipe FFmpeg output to response
|
||||
// Wait for both to complete in parallel
|
||||
const [hasMusic] = await Promise.all([prepareFFmpegPromise, browserPromise.then(b => { browser = b; })]);
|
||||
|
||||
console.log('Starting stream with black frames...');
|
||||
|
||||
// Start FFmpeg immediately - don't wait for page
|
||||
ffmpegProcess = spawn('ffmpeg', ['-loglevel', 'error', '-hide_banner', ...ffmpegArgs], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
// Pipe FFmpeg output to response immediately
|
||||
ffmpegProcess.stdout.pipe(res);
|
||||
|
||||
ffmpegProcess.stderr.on('data', (data) => {
|
||||
@@ -248,6 +255,13 @@ async function streamHandler(req, res, useMusic = false) {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
ffmpegProcess.on('close', (code) => {
|
||||
if (code && code !== 0 && !isCleaningUp) {
|
||||
console.error(`FFmpeg exited with code ${code}`);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
ffmpegProcess.stdin.on('error', (error) => {
|
||||
// Ignore EPIPE errors when client disconnects
|
||||
if (error.code !== 'EPIPE') {
|
||||
@@ -255,41 +269,217 @@ async function streamHandler(req, res, useMusic = false) {
|
||||
}
|
||||
});
|
||||
|
||||
// Start creating page in parallel with FFmpeg starting
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Reduce memory usage by disabling caching
|
||||
await page.setCacheEnabled(false);
|
||||
|
||||
// Inject CSS early to prevent white flash during page load
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
html, body {
|
||||
background-color: #000 !important;
|
||||
}
|
||||
`;
|
||||
document.head?.appendChild(style) || document.documentElement.appendChild(style);
|
||||
});
|
||||
|
||||
// Always start with black frames until the CORRECT page loads
|
||||
let sendBlackFrames = true;
|
||||
let waitingForCorrectUrl = !!lateGeocodePromise; // Track if we're waiting for geocoding to complete
|
||||
|
||||
// Start loading the page in the background - don't wait for it
|
||||
const pageLoadPromise = page.goto(url, { waitUntil: 'load', timeout: 30000 })
|
||||
.then(() => {
|
||||
// Only switch to live frames if we're not waiting for the correct URL
|
||||
if (!waitingForCorrectUrl) {
|
||||
console.log('Page loaded, switching to live frames');
|
||||
sendBlackFrames = false;
|
||||
|
||||
// Hide logo if requested
|
||||
if (hideLogo === 'true') {
|
||||
page.evaluate(() => {
|
||||
const images = document.querySelectorAll('img');
|
||||
images.forEach(img => {
|
||||
if (img.src && img.src.includes('Logo3.png')) {
|
||||
img.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}).catch(err => console.error('Logo hide error:', err));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Page load error:', err.message);
|
||||
if (!waitingForCorrectUrl) {
|
||||
sendBlackFrames = false;
|
||||
}
|
||||
});
|
||||
|
||||
// If we have a late geocoding promise, navigate to correct URL when ready
|
||||
if (lateGeocodePromise) {
|
||||
lateGeocodePromise.then(async (updatedUrl) => {
|
||||
if (!isCleaningUp && page && !page.isClosed() && updatedUrl && updatedUrl !== url) {
|
||||
try {
|
||||
console.log('Updating to correct location...');
|
||||
await page.goto(updatedUrl, { waitUntil: 'load', timeout: 10000 });
|
||||
console.log('Correct location loaded, switching to live frames');
|
||||
waitingForCorrectUrl = false;
|
||||
sendBlackFrames = false; // Now show real frames
|
||||
|
||||
if (hideLogo === 'true') {
|
||||
await page.evaluate(() => {
|
||||
const images = document.querySelectorAll('img');
|
||||
images.forEach(img => {
|
||||
if (img.src && img.src.includes('Logo3.png')) {
|
||||
img.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Location update error:', err.message);
|
||||
waitingForCorrectUrl = false;
|
||||
sendBlackFrames = false;
|
||||
}
|
||||
} else if (!updatedUrl || updatedUrl === url) {
|
||||
// Geocoding completed but URL is the same (was already correct)
|
||||
waitingForCorrectUrl = false;
|
||||
sendBlackFrames = false;
|
||||
}
|
||||
}).catch(() => {
|
||||
// Geocoding failed - use fallback location
|
||||
console.warn('Geocoding failed, using fallback location');
|
||||
waitingForCorrectUrl = false;
|
||||
sendBlackFrames = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Add periodic page refresh to prevent memory leaks and stale content
|
||||
// Refresh every 30 minutes to keep stream stable
|
||||
const pageRefreshInterval = setInterval(async () => {
|
||||
if (!isCleaningUp && page && !page.isClosed()) {
|
||||
try {
|
||||
console.log('Refreshing page for stability...');
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
if (hideLogo === 'true') {
|
||||
await page.evaluate(() => {
|
||||
const images = document.querySelectorAll('img');
|
||||
images.forEach(img => {
|
||||
if (img.src && img.src.includes('Logo3.png')) {
|
||||
img.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Page refresh error:', err.message);
|
||||
}
|
||||
}
|
||||
}, 30 * 60 * 1000); // 30 minutes
|
||||
|
||||
// Capture frames using a sequential loop (avoids overlapping screenshots)
|
||||
// and handle backpressure on ffmpeg.stdin (wait for 'drain' with timeout).
|
||||
const frameInterval = 1000 / fps;
|
||||
let captureLoopActive = true;
|
||||
let consecutiveErrors = 0;
|
||||
const MAX_CONSECUTIVE_ERRORS = 5;
|
||||
|
||||
// Create a black frame buffer once (reused for all black frames)
|
||||
const createBlackFrame = () => {
|
||||
try {
|
||||
// Create a minimal black JPEG with exact dimensions matching the stream
|
||||
const canvas = require('canvas');
|
||||
const canvasObj = canvas.createCanvas(parseInt(width), parseInt(height));
|
||||
const ctx = canvasObj.getContext('2d');
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, parseInt(width), parseInt(height));
|
||||
return canvasObj.toBuffer('image/jpeg', { quality: 0.5 });
|
||||
} catch (err) {
|
||||
console.error('Error creating black frame:', err);
|
||||
// Fallback: create a minimal valid JPEG header (won't look good but won't crash)
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
};
|
||||
|
||||
let blackFrameBuffer = null;
|
||||
|
||||
const captureLoop = async () => {
|
||||
while (!isCleaningUp && captureLoopActive && ffmpegProcess && !ffmpegProcess.killed) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
// Take a screenshot (lower quality a bit to reduce CPU/pipe pressure)
|
||||
const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
|
||||
// Check if page is still valid
|
||||
if (page.isClosed()) {
|
||||
console.error('Page was closed unexpectedly');
|
||||
break;
|
||||
}
|
||||
|
||||
if (ffmpegProcess && ffmpegProcess.stdin.writable && !isCleaningUp) {
|
||||
let screenshot;
|
||||
|
||||
// Send black frames if we're waiting for page load
|
||||
if (sendBlackFrames) {
|
||||
if (!blackFrameBuffer) {
|
||||
blackFrameBuffer = createBlackFrame();
|
||||
}
|
||||
screenshot = blackFrameBuffer;
|
||||
} else {
|
||||
// Take a screenshot with optimized settings
|
||||
screenshot = await page.screenshot({
|
||||
type: 'jpeg',
|
||||
quality: 80,
|
||||
optimizeForSpeed: true,
|
||||
fromSurface: true
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure we have valid data before writing
|
||||
if (screenshot && screenshot.length > 0 && ffmpegProcess && ffmpegProcess.stdin.writable && !isCleaningUp) {
|
||||
const canWrite = ffmpegProcess.stdin.write(screenshot);
|
||||
if (!canWrite) {
|
||||
// Backpressure — wait for drain but don't block forever
|
||||
await new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const currentProcess = ffmpegProcess; // Capture reference to avoid null access
|
||||
const onDrain = () => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } };
|
||||
const onError = () => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } };
|
||||
const timeout = setTimeout(() => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } }, 800);
|
||||
function cleanupListeners() {
|
||||
clearTimeout(timeout);
|
||||
ffmpegProcess.stdin.removeListener('drain', onDrain);
|
||||
ffmpegProcess.stdin.removeListener('error', onError);
|
||||
if (currentProcess && currentProcess.stdin) {
|
||||
currentProcess.stdin.removeListener('drain', onDrain);
|
||||
currentProcess.stdin.removeListener('error', onError);
|
||||
}
|
||||
}
|
||||
if (currentProcess && currentProcess.stdin) {
|
||||
currentProcess.stdin.once('drain', onDrain);
|
||||
currentProcess.stdin.once('error', onError);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
ffmpegProcess.stdin.once('drain', onDrain);
|
||||
ffmpegProcess.stdin.once('error', onError);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reset error counter on success
|
||||
consecutiveErrors = 0;
|
||||
|
||||
} catch (error) {
|
||||
if (!isCleaningUp) {
|
||||
console.error('Capture error:', error.message || error);
|
||||
try { await cleanup(); } catch (e) {}
|
||||
consecutiveErrors++;
|
||||
console.error(`Capture error (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, error.message || error);
|
||||
|
||||
// If too many consecutive errors, give up
|
||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||
console.error('Too many consecutive errors, stopping stream');
|
||||
try { await cleanup(); } catch (e) {}
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait a bit before retrying on error
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -308,9 +498,14 @@ async function streamHandler(req, res, useMusic = false) {
|
||||
const cleanup = async () => {
|
||||
if (isCleaningUp) return;
|
||||
isCleaningUp = true;
|
||||
console.log('Cleaning up stream...');
|
||||
|
||||
// stop capture loop
|
||||
try { captureLoopActive = false; } catch (e) {}
|
||||
|
||||
// Clear page refresh interval
|
||||
try { clearInterval(pageRefreshInterval); } catch (e) {}
|
||||
|
||||
if (ffmpegProcess && !ffmpegProcess.killed) {
|
||||
try {
|
||||
ffmpegProcess.stdin.end();
|
||||
@@ -318,6 +513,14 @@ async function streamHandler(req, res, useMusic = false) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
ffmpegProcess.kill('SIGTERM');
|
||||
|
||||
// Force kill after 5 seconds if still running
|
||||
setTimeout(() => {
|
||||
if (ffmpegProcess && !ffmpegProcess.killed) {
|
||||
ffmpegProcess.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
ffmpegProcess = null;
|
||||
}
|
||||
|
||||
@@ -345,16 +548,56 @@ async function streamHandler(req, res, useMusic = false) {
|
||||
};
|
||||
|
||||
// Handle client disconnect
|
||||
let disconnectLogged = false;
|
||||
|
||||
req.on('close', () => {
|
||||
console.log('Client disconnected');
|
||||
if (!disconnectLogged) {
|
||||
console.log('Client disconnected');
|
||||
disconnectLogged = true;
|
||||
}
|
||||
cleanup();
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
// Ignore expected disconnect errors
|
||||
if (error.code === 'ECONNRESET' || error.code === 'EPIPE') {
|
||||
if (!disconnectLogged) {
|
||||
console.log('Client disconnected');
|
||||
disconnectLogged = true;
|
||||
}
|
||||
} else {
|
||||
console.error('Request error:', error);
|
||||
}
|
||||
cleanup();
|
||||
});
|
||||
|
||||
res.on('error', (error) => {
|
||||
console.error('Response error:', error);
|
||||
// Ignore expected disconnect errors
|
||||
if (error.code === 'ECONNRESET' || error.code === 'EPIPE') {
|
||||
if (!disconnectLogged) {
|
||||
console.log('Client disconnected');
|
||||
disconnectLogged = true;
|
||||
}
|
||||
} else {
|
||||
console.error('Response error:', error);
|
||||
}
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Add keepalive monitoring
|
||||
const keepaliveInterval = setInterval(() => {
|
||||
if (isCleaningUp || !ffmpegProcess || ffmpegProcess.killed) {
|
||||
clearInterval(keepaliveInterval);
|
||||
return;
|
||||
}
|
||||
// Check if connection is still alive
|
||||
if (res.writableEnded || res.socket?.destroyed) {
|
||||
console.log('Connection lost, cleaning up');
|
||||
clearInterval(keepaliveInterval);
|
||||
cleanup();
|
||||
}
|
||||
}, 10000); // Check every 10 seconds
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
if (ffmpegProcess && !ffmpegProcess.killed) ffmpegProcess.kill();
|
||||
@@ -394,30 +637,7 @@ app.get('/weather', async (req, res) => {
|
||||
showMarineForecast = 'false'
|
||||
} = req.query;
|
||||
|
||||
// Default coordinates (Toronto, ON, Canada)
|
||||
let lat = 43.6532;
|
||||
let lon = -79.3832;
|
||||
|
||||
// Try to geocode the city if provided
|
||||
if (city) {
|
||||
try {
|
||||
console.log(`Geocoding city: ${city}`);
|
||||
const geoResult = await geocodeCity(city);
|
||||
lat = geoResult.lat;
|
||||
lon = geoResult.lon;
|
||||
console.log(`Geocoded to: ${geoResult.displayName} (${lat}, ${lon})`);
|
||||
} catch (error) {
|
||||
console.warn(`Geocoding failed for "${city}", using default coordinates:`, error.message);
|
||||
// Fall back to defaults
|
||||
}
|
||||
}
|
||||
|
||||
// Unit conversions for ws4kp
|
||||
// Temperature: 1.00=Celsius, 2.00=Fahrenheit
|
||||
// Wind: 1.00=kph, 2.00=mph
|
||||
// Distance: 1.00=km, 2.00=miles
|
||||
// Pressure: 1.00=mb, 2.00=inHg
|
||||
// Hours: 1.00=12h, 2.00=24h
|
||||
const isMetric = units.toLowerCase() === 'metric';
|
||||
const temperatureUnit = isMetric ? '1.00' : '2.00';
|
||||
const windUnit = isMetric ? '1.00' : '2.00';
|
||||
@@ -425,55 +645,97 @@ app.get('/weather', async (req, res) => {
|
||||
const pressureUnit = isMetric ? '1.00' : '2.00';
|
||||
const hoursFormat = timeFormat === '12h' ? '1.00' : '2.00';
|
||||
|
||||
// Build the ws4kp URL with all the parameters
|
||||
// ws4kp runs on port 8080 inside the container
|
||||
const ws4kpBaseUrl = process.env.WS4KP_URL || 'http://localhost:8080';
|
||||
const ws4kpParams = new URLSearchParams({
|
||||
'hazards-checkbox': showHazards,
|
||||
'current-weather-checkbox': showCurrent,
|
||||
'latest-observations-checkbox': showLatestObservations,
|
||||
'hourly-checkbox': showHourly,
|
||||
'hourly-graph-checkbox': showHourlyGraph,
|
||||
'travel-checkbox': showTravel,
|
||||
'regional-forecast-checkbox': showRegionalForecast,
|
||||
'local-forecast-checkbox': showLocalForecast,
|
||||
'extended-forecast-checkbox': showExtendedForecast,
|
||||
'almanac-checkbox': showAlmanac,
|
||||
'radar-checkbox': showRadar,
|
||||
'marine-forecast-checkbox': showMarineForecast,
|
||||
'aqi-forecast-checkbox': showAQI,
|
||||
'settings-experimentalFeatures-checkbox': 'false',
|
||||
'settings-hideWebamp-checkbox': 'true',
|
||||
'settings-kiosk-checkbox': 'false',
|
||||
'settings-scanLines-checkbox': 'false',
|
||||
'settings-wide-checkbox': 'true',
|
||||
'chkAutoRefresh': 'true',
|
||||
'settings-windUnits-select': windUnit,
|
||||
'settings-marineWindUnits-select': '1.00',
|
||||
'settings-marineWaveHeightUnits-select': '1.00',
|
||||
'settings-temperatureUnits-select': temperatureUnit,
|
||||
'settings-distanceUnits-select': distanceUnit,
|
||||
'settings-pressureUnits-select': pressureUnit,
|
||||
'settings-hoursFormat-select': hoursFormat,
|
||||
'settings-speed-select': '1.00',
|
||||
'latLonQuery': city,
|
||||
'latLon': JSON.stringify({ lat: lat, lon: lon }),
|
||||
'kiosk': 'true'
|
||||
});
|
||||
|
||||
const weatherUrl = `${ws4kpBaseUrl}/?${ws4kpParams.toString()}`;
|
||||
// Function to build URL with given coordinates
|
||||
const buildUrl = (latitude, longitude) => {
|
||||
const ws4kpParams = new URLSearchParams({
|
||||
'hazards-checkbox': showHazards,
|
||||
'current-weather-checkbox': showCurrent,
|
||||
'latest-observations-checkbox': showLatestObservations,
|
||||
'hourly-checkbox': showHourly,
|
||||
'hourly-graph-checkbox': showHourlyGraph,
|
||||
'travel-checkbox': showTravel,
|
||||
'regional-forecast-checkbox': showRegionalForecast,
|
||||
'local-forecast-checkbox': showLocalForecast,
|
||||
'extended-forecast-checkbox': showExtendedForecast,
|
||||
'almanac-checkbox': showAlmanac,
|
||||
'radar-checkbox': showRadar,
|
||||
'marine-forecast-checkbox': showMarineForecast,
|
||||
'aqi-forecast-checkbox': showAQI,
|
||||
'settings-experimentalFeatures-checkbox': 'false',
|
||||
'settings-hideWebamp-checkbox': 'true',
|
||||
'settings-kiosk-checkbox': 'false',
|
||||
'settings-scanLines-checkbox': 'false',
|
||||
'settings-wide-checkbox': 'true',
|
||||
'chkAutoRefresh': 'true',
|
||||
'settings-windUnits-select': windUnit,
|
||||
'settings-marineWindUnits-select': '1.00',
|
||||
'settings-marineWaveHeightUnits-select': '1.00',
|
||||
'settings-temperatureUnits-select': temperatureUnit,
|
||||
'settings-distanceUnits-select': distanceUnit,
|
||||
'settings-pressureUnits-select': pressureUnit,
|
||||
'settings-hoursFormat-select': hoursFormat,
|
||||
'settings-speed-select': '1.00',
|
||||
'latLonQuery': city,
|
||||
'latLon': JSON.stringify({ lat: latitude, lon: longitude }),
|
||||
'kiosk': 'true'
|
||||
});
|
||||
return `${ws4kpBaseUrl}/?${ws4kpParams.toString()}`;
|
||||
};
|
||||
|
||||
console.log(`Weather stream requested for: ${city} (${lat}, ${lon})`);
|
||||
// Start geocoding immediately in the background - don't block anything
|
||||
let lateGeocodePromise = null;
|
||||
let initialUrl = 'data:text/html,<html><body style="margin:0;padding:0;background:#000"></body></html>'; // Dummy black page
|
||||
|
||||
if (city && city !== 'Toronto, ON, CAN') {
|
||||
// Start geocoding - this will be the only URL we load
|
||||
const geocodePromise = Promise.race([
|
||||
geocodeCity(city),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Geocoding timeout')), 1000))
|
||||
]).then(geoResult => {
|
||||
console.log(`Geocoded: ${city} -> ${geoResult.displayName}`);
|
||||
const finalUrl = buildUrl(geoResult.lat, geoResult.lon);
|
||||
console.log(`URL: ${finalUrl}`);
|
||||
return { url: finalUrl, lat: geoResult.lat, lon: geoResult.lon };
|
||||
}).catch(error => {
|
||||
// Geocoding timed out or failed - continue in background
|
||||
return geocodeCity(city).then(geoResult => {
|
||||
const finalUrl = buildUrl(geoResult.lat, geoResult.lon);
|
||||
console.log(`Geocoding completed: ${geoResult.displayName} (${geoResult.lat}, ${geoResult.lon})`);
|
||||
console.log(`Final URL: ${finalUrl}`);
|
||||
return { url: finalUrl, lat: geoResult.lat, lon: geoResult.lon, isLate: true };
|
||||
}).catch(err => {
|
||||
console.warn(`Geocoding failed: ${err.message}`);
|
||||
// Fall back to Toronto if geocoding completely fails
|
||||
const fallbackUrl = buildUrl(43.6532, -79.3832);
|
||||
return { url: fallbackUrl, lat: 43.6532, lon: -79.3832, isLate: true };
|
||||
});
|
||||
});
|
||||
|
||||
// Always wait for geocoding to complete (or timeout and continue in background)
|
||||
lateGeocodePromise = geocodePromise.then(result => {
|
||||
return result.url;
|
||||
});
|
||||
} else {
|
||||
// Toronto - use directly
|
||||
const lat = 43.6532;
|
||||
const lon = -79.3832;
|
||||
initialUrl = buildUrl(lat, lon);
|
||||
console.log(`URL: ${initialUrl}`);
|
||||
}
|
||||
|
||||
console.log(`Stream starting: ${city}`);
|
||||
|
||||
// Forward to the main stream endpoint WITH MUSIC
|
||||
req.query.url = weatherUrl;
|
||||
req.query.url = initialUrl;
|
||||
req.query.width = width;
|
||||
req.query.height = height;
|
||||
req.query.fps = fps;
|
||||
req.query.hideLogo = hideLogo;
|
||||
|
||||
// Call the stream handler with music enabled
|
||||
return streamHandler(req, res, true);
|
||||
// Call the stream handler with music enabled and late geocode promise
|
||||
return streamHandler(req, res, true, lateGeocodePromise);
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
@@ -6,10 +6,16 @@
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"keywords": ["puppeteer", "ffmpeg", "hls", "streaming"],
|
||||
"keywords": [
|
||||
"puppeteer",
|
||||
"ffmpeg",
|
||||
"hls",
|
||||
"streaming"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"canvas": "^2.11.2",
|
||||
"express": "^4.18.2",
|
||||
"puppeteer": "^24.15.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user