const express = require('express'); const puppeteer = require('puppeteer'); const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const https = require('https'); const app = express(); const PORT = process.env.PORT || 3000; const MUSIC_PATH = process.env.MUSIC_PATH || '/music'; // Geocode city to lat/lon using Nominatim (OpenStreetMap) async function geocodeCity(cityQuery) { return new Promise((resolve, reject) => { const encodedQuery = encodeURIComponent(cityQuery); const url = `https://nominatim.openstreetmap.org/search?q=${encodedQuery}&format=json&limit=1`; const options = { headers: { 'User-Agent': 'webpage-to-hls-streaming-app/1.0' } }; https.get(url, options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const results = JSON.parse(data); if (results && results.length > 0) { resolve({ lat: parseFloat(results[0].lat), lon: parseFloat(results[0].lon), displayName: results[0].display_name }); } else { reject(new Error('No results found')); } } catch (error) { reject(error); } }); }).on('error', (error) => { reject(error); }); }); } // Get random music file function getRandomMusicFile() { try { if (!fs.existsSync(MUSIC_PATH)) { return null; } const files = fs.readdirSync(MUSIC_PATH).filter(f => f.endsWith('.ogg')); if (files.length === 0) { return null; } const randomFile = files[Math.floor(Math.random() * files.length)]; return path.join(MUSIC_PATH, randomFile); } catch (error) { console.error('Error getting music file:', error); return null; } } // Get all music files for shuffling function getAllMusicFiles() { try { if (!fs.existsSync(MUSIC_PATH)) { return []; } const files = fs.readdirSync(MUSIC_PATH).filter(f => f.endsWith('.ogg')); return files.map(f => path.join(MUSIC_PATH, f)); } catch (error) { console.error('Error getting music files:', error); return []; } } // Shuffle array in place function shuffleArray(array) { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } return array; } // Main streaming handler async function streamHandler(req, res, useMusic = false) { const { url, width = 1920, height = 1080, fps = 30, hideLogo = 'false' } = req.query; if (!url) { return res.status(400).send('URL parameter is required'); } // Validate URL try { new URL(url); } catch (error) { return res.status(400).send('Invalid URL'); } let browser = null; let ffmpegProcess = null; 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 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]); // 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 const currentShuffle = shuffleArray([...allMusicFiles]); currentShuffle.forEach(file => { playlistContent += `file '${file}'\n`; }); } 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', '-f', 'image2pipe', '-framerate', fps.toString(), '-i', 'pipe:0', '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-pix_fmt', 'yuv420p', '-g', (fps * 2).toString(), '-f', 'hls', '-hls_time', '2', '-hls_list_size', '5', '-hls_flags', 'delete_segments', 'pipe:1' ); } // Start FFmpeg ffmpegProcess = spawn('ffmpeg', ffmpegArgs); // Pipe FFmpeg output to response ffmpegProcess.stdout.pipe(res); ffmpegProcess.stderr.on('data', (data) => { // Suppress verbose FFmpeg output // console.error(`FFmpeg: ${data}`); }); ffmpegProcess.on('error', (error) => { console.error('FFmpeg error:', error); cleanup(); }); ffmpegProcess.stdin.on('error', (error) => { // Ignore EPIPE errors when client disconnects if (error.code !== 'EPIPE') { console.error('FFmpeg stdin error:', error); } }); // 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; 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 }); if (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 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); } ffmpegProcess.stdin.once('drain', onDrain); ffmpegProcess.stdin.once('error', onError); }); } } } catch (error) { if (!isCleaningUp) { console.error('Capture error:', error.message || error); try { await cleanup(); } catch (e) {} break; } } const elapsed = Date.now() - start; const wait = Math.max(0, frameInterval - elapsed); // Wait the remaining time before next frame if (wait > 0) await new Promise(r => setTimeout(r, wait)); } }; // Start the capture loop (no overlapping runs) captureLoop(); // Cleanup function const cleanup = async () => { if (isCleaningUp) return; isCleaningUp = true; // stop capture loop try { captureLoopActive = false; } catch (e) {} if (ffmpegProcess && !ffmpegProcess.killed) { try { ffmpegProcess.stdin.end(); } catch (err) { // Ignore errors during cleanup } ffmpegProcess.kill('SIGTERM'); ffmpegProcess = null; } // Clean up temporary playlist file if (playlistFile) { try { fs.unlinkSync(playlistFile); } catch (err) { // Ignore errors during cleanup } } if (browser) { try { await browser.close(); } catch (err) { // Ignore errors during cleanup } browser = null; } if (!res.headersSent && !res.writableEnded) { res.end(); } }; // Handle client disconnect req.on('close', () => { console.log('Client disconnected'); cleanup(); }); res.on('error', (error) => { console.error('Response error:', error); cleanup(); }); } catch (error) { console.error('Error:', error); if (ffmpegProcess && !ffmpegProcess.killed) ffmpegProcess.kill(); if (browser) await browser.close(); if (!res.headersSent) { res.status(500).send('Internal server error'); } } } // Stream endpoint app.get('/stream', (req, res) => streamHandler(req, res, false)); app.get('/weather', async (req, res) => { const { city = 'Toronto, ON, CAN', width = 1920, height = 1080, fps = 30, hideLogo = 'false', units = 'metric', // 'metric' or 'imperial' timeFormat = '24h', // '12h' or '24h' // Forecast section toggles - commonly used sections (default: true) showHazards = 'true', showCurrent = 'true', showHourly = 'true', showHourlyGraph = 'true', showLocalForecast = 'true', showExtendedForecast = 'true', showRadar = 'true', showAQI = 'true', showAlmanac = 'true', showLatestObservations = 'true', showRegionalForecast = 'true', // Less common sections (default: false) showTravel = 'false', 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'; const distanceUnit = isMetric ? '1.00' : '2.00'; 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()}`; console.log(`Weather stream requested for: ${city} (${lat}, ${lon})`); // Forward to the main stream endpoint WITH MUSIC req.query.url = weatherUrl; 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); }); app.get('/health', (req, res) => { res.send('OK'); }); app.listen(PORT, () => { console.log(`Webpage to HLS server running on port ${PORT}`); console.log(`Usage: http://localhost:${PORT}/stream?url=http://example.com`); console.log(`Weather: http://localhost:${PORT}/weather?city=YourCity`); });