Initial Commit
This commit is contained in:
487
index.js
Normal file
487
index.js
Normal file
@@ -0,0 +1,487 @@
|
||||
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`);
|
||||
});
|
||||
Reference in New Issue
Block a user