1
0

Lots of attempts at optimization

This commit is contained in:
2025-11-07 15:58:12 -05:00
parent b2c65553a6
commit f59b0de539
3 changed files with 456 additions and 176 deletions

View File

@@ -1,12 +1,24 @@
FROM ghcr.io/mwood77/ws4kp-international:latest 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 \ RUN apk add --no-cache \
chromium \ chromium \
ffmpeg \ ffmpeg \
font-noto-emoji \ font-noto-emoji \
wget \ 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 # Download and extract Weatherscan music
RUN mkdir -p /music && \ RUN mkdir -p /music && \

604
index.js
View File

@@ -92,7 +92,7 @@ function shuffleArray(array) {
} }
// Main streaming handler // 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; const { url, width = 1920, height = 1080, fps = 30, hideLogo = 'false' } = req.query;
if (!url) { if (!url) {
@@ -111,108 +111,74 @@ async function streamHandler(req, res, useMusic = false) {
let isCleaningUp = false; let isCleaningUp = false;
try { try {
console.log(`Starting stream for: ${url}`);
// Set HLS headers // Set HLS headers
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
// Launch browser // Build FFmpeg command and playlist in parallel with browser launch
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
const ffmpegArgs = []; const ffmpegArgs = [];
let playlistFile = null; let playlistFile = null;
if (useMusic) { const prepareFFmpegPromise = (async () => {
// Get all music files and shuffle them if (useMusic) {
const allMusicFiles = getAllMusicFiles(); // Get all music files and shuffle them
if (allMusicFiles.length > 0) { const allMusicFiles = getAllMusicFiles();
const shuffledFiles = shuffleArray([...allMusicFiles]); 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 // Build playlist content - just 1 repetition since we loop infinitely with -stream_loop
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
const currentShuffle = shuffleArray([...allMusicFiles]); const currentShuffle = shuffleArray([...allMusicFiles]);
currentShuffle.forEach(file => { const playlistLines = currentShuffle.map(f => `file '${f}'`);
playlistContent += `file '${file}'\n`;
}); 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) // Video only (no music)
ffmpegArgs.push( ffmpegArgs.push(
'-use_wallclock_as_timestamps', '1', '-use_wallclock_as_timestamps', '1',
@@ -223,19 +189,60 @@ async function streamHandler(req, res, useMusic = false) {
'-preset', 'ultrafast', '-preset', 'ultrafast',
'-tune', 'zerolatency', '-tune', 'zerolatency',
'-pix_fmt', 'yuv420p', '-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', '-f', 'hls',
'-hls_time', '2', '-hls_time', '1', // Smaller segments for faster startup
'-hls_list_size', '5', '-hls_list_size', '3', // Fewer segments in playlist
'-hls_flags', 'delete_segments', '-hls_flags', 'delete_segments+omit_endlist',
'-hls_start_number_source', 'epoch',
'-start_number', '0',
'-flush_packets', '1', // Flush packets immediately
'pipe:1' 'pipe:1'
); );
} return false;
})();
// Start FFmpeg // Launch browser in parallel with FFmpeg preparation
ffmpegProcess = spawn('ffmpeg', ffmpegArgs); 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.stdout.pipe(res);
ffmpegProcess.stderr.on('data', (data) => { ffmpegProcess.stderr.on('data', (data) => {
@@ -248,6 +255,13 @@ async function streamHandler(req, res, useMusic = false) {
cleanup(); cleanup();
}); });
ffmpegProcess.on('close', (code) => {
if (code && code !== 0 && !isCleaningUp) {
console.error(`FFmpeg exited with code ${code}`);
cleanup();
}
});
ffmpegProcess.stdin.on('error', (error) => { ffmpegProcess.stdin.on('error', (error) => {
// Ignore EPIPE errors when client disconnects // Ignore EPIPE errors when client disconnects
if (error.code !== 'EPIPE') { 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) // Capture frames using a sequential loop (avoids overlapping screenshots)
// and handle backpressure on ffmpeg.stdin (wait for 'drain' with timeout). // and handle backpressure on ffmpeg.stdin (wait for 'drain' with timeout).
const frameInterval = 1000 / fps; const frameInterval = 1000 / fps;
let captureLoopActive = true; 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 () => { const captureLoop = async () => {
while (!isCleaningUp && captureLoopActive && ffmpegProcess && !ffmpegProcess.killed) { while (!isCleaningUp && captureLoopActive && ffmpegProcess && !ffmpegProcess.killed) {
const start = Date.now(); const start = Date.now();
try { try {
// Take a screenshot (lower quality a bit to reduce CPU/pipe pressure) // Check if page is still valid
const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 }); 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); const canWrite = ffmpegProcess.stdin.write(screenshot);
if (!canWrite) { if (!canWrite) {
// Backpressure — wait for drain but don't block forever // Backpressure — wait for drain but don't block forever
await new Promise((resolve) => { await new Promise((resolve) => {
let resolved = false; let resolved = false;
const currentProcess = ffmpegProcess; // Capture reference to avoid null access
const onDrain = () => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } }; const onDrain = () => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } };
const onError = () => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } }; const onError = () => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } };
const timeout = setTimeout(() => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } }, 800); const timeout = setTimeout(() => { if (!resolved) { resolved = true; cleanupListeners(); resolve(); } }, 800);
function cleanupListeners() { function cleanupListeners() {
clearTimeout(timeout); clearTimeout(timeout);
ffmpegProcess.stdin.removeListener('drain', onDrain); if (currentProcess && currentProcess.stdin) {
ffmpegProcess.stdin.removeListener('error', onError); 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) { } catch (error) {
if (!isCleaningUp) { if (!isCleaningUp) {
console.error('Capture error:', error.message || error); consecutiveErrors++;
try { await cleanup(); } catch (e) {} 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; break;
} }
} }
@@ -308,9 +498,14 @@ async function streamHandler(req, res, useMusic = false) {
const cleanup = async () => { const cleanup = async () => {
if (isCleaningUp) return; if (isCleaningUp) return;
isCleaningUp = true; isCleaningUp = true;
console.log('Cleaning up stream...');
// stop capture loop // stop capture loop
try { captureLoopActive = false; } catch (e) {} try { captureLoopActive = false; } catch (e) {}
// Clear page refresh interval
try { clearInterval(pageRefreshInterval); } catch (e) {}
if (ffmpegProcess && !ffmpegProcess.killed) { if (ffmpegProcess && !ffmpegProcess.killed) {
try { try {
ffmpegProcess.stdin.end(); ffmpegProcess.stdin.end();
@@ -318,6 +513,14 @@ async function streamHandler(req, res, useMusic = false) {
// Ignore errors during cleanup // Ignore errors during cleanup
} }
ffmpegProcess.kill('SIGTERM'); ffmpegProcess.kill('SIGTERM');
// Force kill after 5 seconds if still running
setTimeout(() => {
if (ffmpegProcess && !ffmpegProcess.killed) {
ffmpegProcess.kill('SIGKILL');
}
}, 5000);
ffmpegProcess = null; ffmpegProcess = null;
} }
@@ -345,16 +548,56 @@ async function streamHandler(req, res, useMusic = false) {
}; };
// Handle client disconnect // Handle client disconnect
let disconnectLogged = false;
req.on('close', () => { 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(); cleanup();
}); });
res.on('error', (error) => { 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(); 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) { } catch (error) {
console.error('Error:', error); console.error('Error:', error);
if (ffmpegProcess && !ffmpegProcess.killed) ffmpegProcess.kill(); if (ffmpegProcess && !ffmpegProcess.killed) ffmpegProcess.kill();
@@ -394,30 +637,7 @@ app.get('/weather', async (req, res) => {
showMarineForecast = 'false' showMarineForecast = 'false'
} = req.query; } = 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 // 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 isMetric = units.toLowerCase() === 'metric';
const temperatureUnit = isMetric ? '1.00' : '2.00'; const temperatureUnit = isMetric ? '1.00' : '2.00';
const windUnit = 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 pressureUnit = isMetric ? '1.00' : '2.00';
const hoursFormat = timeFormat === '12h' ? '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 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 // Forward to the main stream endpoint WITH MUSIC
req.query.url = weatherUrl; req.query.url = initialUrl;
req.query.width = width; req.query.width = width;
req.query.height = height; req.query.height = height;
req.query.fps = fps; req.query.fps = fps;
req.query.hideLogo = hideLogo; req.query.hideLogo = hideLogo;
// Call the stream handler with music enabled // Call the stream handler with music enabled and late geocode promise
return streamHandler(req, res, true); return streamHandler(req, res, true, lateGeocodePromise);
}); });
app.get('/health', (req, res) => { app.get('/health', (req, res) => {

View File

@@ -6,10 +6,16 @@
"scripts": { "scripts": {
"start": "node index.js" "start": "node index.js"
}, },
"keywords": ["puppeteer", "ffmpeg", "hls", "streaming"], "keywords": [
"puppeteer",
"ffmpeg",
"hls",
"streaming"
],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"canvas": "^2.11.2",
"express": "^4.18.2", "express": "^4.18.2",
"puppeteer": "^24.15.0" "puppeteer": "^24.15.0"
} }