More stability & defaults
This commit is contained in:
@@ -41,6 +41,7 @@ WORKDIR /streaming-app
|
|||||||
COPY package.json yarn.lock* ./
|
COPY package.json yarn.lock* ./
|
||||||
RUN npm install -g yarn && yarn install --frozen-lockfile || yarn install
|
RUN npm install -g yarn && yarn install --frozen-lockfile || yarn install
|
||||||
COPY index.js ./
|
COPY index.js ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
# Expose both ports
|
# Expose both ports
|
||||||
EXPOSE 3000 8080
|
EXPOSE 3000 8080
|
||||||
|
|||||||
@@ -130,11 +130,12 @@ ffmpeg -i "http://localhost:3000/stream?url=http://example.com" \
|
|||||||
- `showRadar`: Weather radar
|
- `showRadar`: Weather radar
|
||||||
- `showAQI`: Air Quality Index
|
- `showAQI`: Air Quality Index
|
||||||
- `showAlmanac`: Weather almanac (historical data, records)
|
- `showAlmanac`: Weather almanac (historical data, records)
|
||||||
|
- **Specialized sections** (default: false):
|
||||||
- `showLatestObservations`: Real-time data from nearby stations
|
- `showLatestObservations`: Real-time data from nearby stations
|
||||||
- `showRegionalForecast`: Regional forecast for surrounding areas
|
- `showRegionalForecast`: Regional forecast for surrounding areas
|
||||||
- **Specialized sections** (default: false):
|
|
||||||
- `showTravel`: Travel forecast for major destinations
|
- `showTravel`: Travel forecast for major destinations
|
||||||
- `showMarineForecast`: Marine/coastal forecast
|
- `showMarineForecast`: Marine/coastal forecast
|
||||||
|
- **Note**: Sections disabled by default (`showLatestObservations`, `showRegionalForecast`, `showTravel`, `showMarineForecast`) may display incorrect or incomplete information for locations outside the United States, as they rely on NOAA data sources.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
|
|||||||
909
index.js
909
index.js
@@ -1,726 +1,34 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const puppeteer = require('puppeteer');
|
const { streamHandler } = require('./src/streamHandler');
|
||||||
const { spawn } = require('child_process');
|
const { geocodeCity } = require('./src/geocode');
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const https = require('https');
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const MUSIC_PATH = process.env.MUSIC_PATH || '/music';
|
const MUSIC_PATH = process.env.MUSIC_PATH || '/music';
|
||||||
|
|
||||||
// Geocode city to lat/lon using Nominatim (OpenStreetMap)
|
/**
|
||||||
async function geocodeCity(cityQuery) {
|
* Build WS4KP weather URL with given coordinates and settings
|
||||||
return new Promise((resolve, reject) => {
|
*/
|
||||||
const encodedQuery = encodeURIComponent(cityQuery);
|
function buildWeatherUrl(latitude, longitude, settings) {
|
||||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodedQuery}&format=json&limit=1`;
|
const {
|
||||||
|
city,
|
||||||
const options = {
|
showHazards,
|
||||||
headers: {
|
showCurrent,
|
||||||
'User-Agent': 'webpage-to-hls-streaming-app/1.0'
|
showLatestObservations,
|
||||||
}
|
showHourly,
|
||||||
};
|
showHourlyGraph,
|
||||||
|
showTravel,
|
||||||
|
showRegionalForecast,
|
||||||
|
showLocalForecast,
|
||||||
|
showExtendedForecast,
|
||||||
|
showAlmanac,
|
||||||
|
showRadar,
|
||||||
|
showMarineForecast,
|
||||||
|
showAQI,
|
||||||
|
units,
|
||||||
|
timeFormat
|
||||||
|
} = settings;
|
||||||
|
|
||||||
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, lateGeocodePromise = null) {
|
|
||||||
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 {
|
|
||||||
// Set HLS headers
|
|
||||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
|
||||||
|
|
||||||
// Build FFmpeg command and playlist in parallel with browser launch
|
|
||||||
const ffmpegArgs = [];
|
|
||||||
let playlistFile = null;
|
|
||||||
|
|
||||||
const prepareFFmpegPromise = (async () => {
|
|
||||||
if (useMusic) {
|
|
||||||
// Get all music files and shuffle them
|
|
||||||
const allMusicFiles = getAllMusicFiles();
|
|
||||||
console.log(`Found ${allMusicFiles.length} music files in ${MUSIC_PATH}`);
|
|
||||||
if (allMusicFiles.length > 0) {
|
|
||||||
// Create a temporary concat playlist file
|
|
||||||
playlistFile = path.join('/tmp', `playlist-${Date.now()}.txt`);
|
|
||||||
|
|
||||||
// Build playlist content - repeat enough times for ~24 hours of playback
|
|
||||||
// Assuming avg 3 min per track, repeat enough to cover a full day
|
|
||||||
const currentShuffle = shuffleArray([...allMusicFiles]);
|
|
||||||
const repetitions = Math.max(20, Math.ceil(480 / allMusicFiles.length)); // At least 480 tracks (~24hrs)
|
|
||||||
const playlistLines = [];
|
|
||||||
for (let i = 0; i < repetitions; i++) {
|
|
||||||
// Re-shuffle each repetition for more variety
|
|
||||||
const shuffled = shuffleArray([...allMusicFiles]);
|
|
||||||
shuffled.forEach(f => playlistLines.push(`file '${f}'`));
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(playlistFile, playlistLines.join('\n'));
|
|
||||||
// console.log(`Created playlist with ${allMusicFiles.length} tracks x${repetitions} repetitions (~${playlistLines.length} total 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 with audio filtering for smooth transitions
|
|
||||||
ffmpegArgs.push(
|
|
||||||
// Use audio filter to ensure smooth transitions and consistent format
|
|
||||||
'-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0,aformat=sample_rates=44100:channel_layouts=stereo',
|
|
||||||
'-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
|
|
||||||
'-ac', '2', // Stereo output
|
|
||||||
'-avoid_negative_ts', 'make_zero', // Prevent timestamp issues
|
|
||||||
'-fflags', '+genpts+igndts', // Generate presentation timestamps, ignore decode timestamps
|
|
||||||
'-max_interleave_delta', '0', // Reduce audio/video sync issues during transitions
|
|
||||||
'-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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.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', '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;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// 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 }
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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) => {
|
|
||||||
const message = data.toString();
|
|
||||||
// Log important warnings about audio discontinuities or errors
|
|
||||||
if (message.includes('Non-monotonous DTS') ||
|
|
||||||
message.includes('Application provided invalid') ||
|
|
||||||
message.includes('past duration') ||
|
|
||||||
message.includes('Error while decoding stream') ||
|
|
||||||
message.includes('Invalid data found')) {
|
|
||||||
console.warn(`FFmpeg warning: ${message.trim()}`);
|
|
||||||
}
|
|
||||||
// Uncomment for full FFmpeg output during debugging:
|
|
||||||
// console.error(`FFmpeg: ${message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
ffmpegProcess.on('error', (error) => {
|
|
||||||
console.error('FFmpeg error:', error);
|
|
||||||
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') {
|
|
||||||
console.error('FFmpeg stdin error:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// Helper function to wait for page to be fully loaded with all resources
|
|
||||||
const waitForPageFullyLoaded = async (page, hideLogo) => {
|
|
||||||
try {
|
|
||||||
// Wait for networkidle2 (waits until no more than 2 network connections for 500ms)
|
|
||||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
|
|
||||||
console.log('Page network idle, waiting for content to render...');
|
|
||||||
|
|
||||||
// Additional wait for dynamic content to fully render (weather data, radar, etc.)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
||||||
|
|
||||||
// Verify content is actually visible before switching
|
|
||||||
const hasVisibleContent = await page.evaluate(() => {
|
|
||||||
// Check if there's actual content beyond just background
|
|
||||||
const body = document.body;
|
|
||||||
if (!body) return false;
|
|
||||||
|
|
||||||
// Check for common weather page elements
|
|
||||||
const hasContent = document.querySelector('canvas') ||
|
|
||||||
document.querySelector('.weather-display') ||
|
|
||||||
document.querySelector('img[src*="radar"]') ||
|
|
||||||
document.querySelectorAll('div').length > 10;
|
|
||||||
return hasContent;
|
|
||||||
}).catch(() => false);
|
|
||||||
|
|
||||||
if (!hasVisibleContent) {
|
|
||||||
console.log('Content not fully visible yet, waiting additional time...');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Page fully loaded with all resources, switching to live frames');
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Page load error:', err.message);
|
|
||||||
// Still show the page even if timeout occurs
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start loading the page in the background - don't wait for it
|
|
||||||
const pageLoadPromise = waitForPageFullyLoaded(page, hideLogo)
|
|
||||||
.then(async (loaded) => {
|
|
||||||
// Only switch to live frames if we're not waiting for the correct URL
|
|
||||||
if (!waitingForCorrectUrl && loaded) {
|
|
||||||
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 promise error:', err.message);
|
|
||||||
if (!waitingForCorrectUrl) {
|
|
||||||
// Show page anyway after error to avoid black screen forever
|
|
||||||
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...');
|
|
||||||
// Ensure black frames continue during navigation
|
|
||||||
sendBlackFrames = true;
|
|
||||||
|
|
||||||
// Wait for networkidle2 to ensure all resources load
|
|
||||||
await page.goto(updatedUrl, { waitUntil: 'networkidle2', timeout: 45000 });
|
|
||||||
console.log('Correct location network idle, waiting for content...');
|
|
||||||
|
|
||||||
// Additional wait for dynamic content
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
||||||
|
|
||||||
// Verify content is visible
|
|
||||||
const hasVisibleContent = await page.evaluate(() => {
|
|
||||||
const body = document.body;
|
|
||||||
if (!body) return false;
|
|
||||||
const hasContent = document.querySelector('canvas') ||
|
|
||||||
document.querySelector('.weather-display') ||
|
|
||||||
document.querySelector('img[src*="radar"]') ||
|
|
||||||
document.querySelectorAll('div').length > 10;
|
|
||||||
return hasContent;
|
|
||||||
}).catch(() => false);
|
|
||||||
|
|
||||||
if (!hasVisibleContent) {
|
|
||||||
console.log('Content not fully visible yet, waiting additional time...');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Correct location fully 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;
|
|
||||||
// Show page anyway to avoid black screen forever
|
|
||||||
sendBlackFrames = false;
|
|
||||||
}
|
|
||||||
} else if (!updatedUrl || updatedUrl === url) {
|
|
||||||
// Geocoding completed but URL is the same (was already correct)
|
|
||||||
// Wait for the initial page load to complete before switching
|
|
||||||
console.log('Using initial URL, waiting for page load to complete...');
|
|
||||||
}
|
|
||||||
}).catch(() => {
|
|
||||||
// Geocoding failed - use fallback location
|
|
||||||
console.warn('Geocoding failed, waiting for fallback location to load');
|
|
||||||
// Let the initial page load complete before switching
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
// Check if page is still valid
|
|
||||||
if (page.isClosed()) {
|
|
||||||
console.error('Page was closed unexpectedly');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset error counter on success
|
|
||||||
consecutiveErrors = 0;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (!isCleaningUp) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
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();
|
|
||||||
} catch (err) {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
let disconnectLogged = false;
|
|
||||||
|
|
||||||
req.on('close', () => {
|
|
||||||
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) => {
|
|
||||||
// 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();
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Unit conversions for ws4kp
|
|
||||||
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';
|
||||||
@@ -730,101 +38,152 @@ app.get('/weather', async (req, res) => {
|
|||||||
|
|
||||||
const ws4kpBaseUrl = process.env.WS4KP_URL || 'http://localhost:8080';
|
const ws4kpBaseUrl = process.env.WS4KP_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
// Function to build URL with given coordinates
|
const ws4kpParams = new URLSearchParams({
|
||||||
const buildUrl = (latitude, longitude) => {
|
'hazards-checkbox': showHazards,
|
||||||
const ws4kpParams = new URLSearchParams({
|
'current-weather-checkbox': showCurrent,
|
||||||
'hazards-checkbox': showHazards,
|
'latest-observations-checkbox': showLatestObservations,
|
||||||
'current-weather-checkbox': showCurrent,
|
'hourly-checkbox': showHourly,
|
||||||
'latest-observations-checkbox': showLatestObservations,
|
'hourly-graph-checkbox': showHourlyGraph,
|
||||||
'hourly-checkbox': showHourly,
|
'travel-checkbox': showTravel,
|
||||||
'hourly-graph-checkbox': showHourlyGraph,
|
'regional-forecast-checkbox': showRegionalForecast,
|
||||||
'travel-checkbox': showTravel,
|
'local-forecast-checkbox': showLocalForecast,
|
||||||
'regional-forecast-checkbox': showRegionalForecast,
|
'extended-forecast-checkbox': showExtendedForecast,
|
||||||
'local-forecast-checkbox': showLocalForecast,
|
'almanac-checkbox': showAlmanac,
|
||||||
'extended-forecast-checkbox': showExtendedForecast,
|
'radar-checkbox': showRadar,
|
||||||
'almanac-checkbox': showAlmanac,
|
'marine-forecast-checkbox': showMarineForecast,
|
||||||
'radar-checkbox': showRadar,
|
'aqi-forecast-checkbox': showAQI,
|
||||||
'marine-forecast-checkbox': showMarineForecast,
|
'settings-experimentalFeatures-checkbox': 'false',
|
||||||
'aqi-forecast-checkbox': showAQI,
|
'settings-hideWebamp-checkbox': 'true',
|
||||||
'settings-experimentalFeatures-checkbox': 'false',
|
'settings-kiosk-checkbox': 'false',
|
||||||
'settings-hideWebamp-checkbox': 'true',
|
'settings-scanLines-checkbox': 'false',
|
||||||
'settings-kiosk-checkbox': 'false',
|
'settings-wide-checkbox': 'true',
|
||||||
'settings-scanLines-checkbox': 'false',
|
'chkAutoRefresh': 'true',
|
||||||
'settings-wide-checkbox': 'true',
|
'settings-windUnits-select': windUnit,
|
||||||
'chkAutoRefresh': 'true',
|
'settings-marineWindUnits-select': '1.00',
|
||||||
'settings-windUnits-select': windUnit,
|
'settings-marineWaveHeightUnits-select': '1.00',
|
||||||
'settings-marineWindUnits-select': '1.00',
|
'settings-temperatureUnits-select': temperatureUnit,
|
||||||
'settings-marineWaveHeightUnits-select': '1.00',
|
'settings-distanceUnits-select': distanceUnit,
|
||||||
'settings-temperatureUnits-select': temperatureUnit,
|
'settings-pressureUnits-select': pressureUnit,
|
||||||
'settings-distanceUnits-select': distanceUnit,
|
'settings-hoursFormat-select': hoursFormat,
|
||||||
'settings-pressureUnits-select': pressureUnit,
|
'settings-speed-select': '1.00',
|
||||||
'settings-hoursFormat-select': hoursFormat,
|
'latLonQuery': city,
|
||||||
'settings-speed-select': '1.00',
|
'latLon': JSON.stringify({ lat: latitude, lon: longitude }),
|
||||||
'latLonQuery': city,
|
'kiosk': 'true'
|
||||||
'latLon': JSON.stringify({ lat: latitude, lon: longitude }),
|
});
|
||||||
'kiosk': 'true'
|
|
||||||
});
|
return `${ws4kpBaseUrl}/?${ws4kpParams.toString()}`;
|
||||||
return `${ws4kpBaseUrl}/?${ws4kpParams.toString()}`;
|
}
|
||||||
|
|
||||||
|
// Basic stream endpoint (no music)
|
||||||
|
app.get('/stream', (req, res) => {
|
||||||
|
streamHandler(req, res, {
|
||||||
|
useMusic: false,
|
||||||
|
musicPath: MUSIC_PATH
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Weather endpoint (with music)
|
||||||
|
app.get('/weather', async (req, res) => {
|
||||||
|
const {
|
||||||
|
city = 'Toronto, ON, CAN',
|
||||||
|
width = 1920,
|
||||||
|
height = 1080,
|
||||||
|
fps = 30,
|
||||||
|
hideLogo = 'false',
|
||||||
|
units = 'metric',
|
||||||
|
timeFormat = '24h',
|
||||||
|
showHazards = 'true',
|
||||||
|
showCurrent = 'true',
|
||||||
|
showHourly = 'true',
|
||||||
|
showHourlyGraph = 'true',
|
||||||
|
showLocalForecast = 'true',
|
||||||
|
showExtendedForecast = 'true',
|
||||||
|
showRadar = 'true',
|
||||||
|
showAQI = 'true',
|
||||||
|
showAlmanac = 'true',
|
||||||
|
showLatestObservations = 'false',
|
||||||
|
showRegionalForecast = 'false',
|
||||||
|
showTravel = 'false',
|
||||||
|
showMarineForecast = 'false'
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const weatherSettings = {
|
||||||
|
city,
|
||||||
|
showHazards,
|
||||||
|
showCurrent,
|
||||||
|
showLatestObservations,
|
||||||
|
showHourly,
|
||||||
|
showHourlyGraph,
|
||||||
|
showTravel,
|
||||||
|
showRegionalForecast,
|
||||||
|
showLocalForecast,
|
||||||
|
showExtendedForecast,
|
||||||
|
showAlmanac,
|
||||||
|
showRadar,
|
||||||
|
showMarineForecast,
|
||||||
|
showAQI,
|
||||||
|
units,
|
||||||
|
timeFormat
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start geocoding immediately in the background - don't block anything
|
|
||||||
let lateGeocodePromise = null;
|
let lateGeocodePromise = null;
|
||||||
let initialUrl = 'data:text/html,<html><body style="margin:0;padding:0;background:#000"></body></html>'; // Dummy black page
|
let initialUrl = 'data:text/html,<html><body style="margin:0;padding:0;background:#000"></body></html>';
|
||||||
|
|
||||||
if (city && city !== 'Toronto, ON, CAN') {
|
if (city && city !== 'Toronto, ON, CAN') {
|
||||||
// Start geocoding - this will be the only URL we load
|
// Try quick geocode first
|
||||||
const geocodePromise = Promise.race([
|
const geocodePromise = Promise.race([
|
||||||
geocodeCity(city),
|
geocodeCity(city),
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Geocoding timeout')), 1000))
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Geocoding timeout')), 1000))
|
||||||
]).then(geoResult => {
|
]).then(geoResult => {
|
||||||
console.log(`Geocoded: ${city} -> ${geoResult.displayName}`);
|
console.log(`Geocoded: ${city} -> ${geoResult.displayName}`);
|
||||||
const finalUrl = buildUrl(geoResult.lat, geoResult.lon);
|
const finalUrl = buildWeatherUrl(geoResult.lat, geoResult.lon, weatherSettings);
|
||||||
console.log(`URL: ${finalUrl}`);
|
console.log(`URL: ${finalUrl}`);
|
||||||
return { url: finalUrl, lat: geoResult.lat, lon: geoResult.lon };
|
return { url: finalUrl, lat: geoResult.lat, lon: geoResult.lon };
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
// Geocoding timed out or failed - continue in background
|
// Continue geocoding in background
|
||||||
return geocodeCity(city).then(geoResult => {
|
return geocodeCity(city).then(geoResult => {
|
||||||
const finalUrl = buildUrl(geoResult.lat, geoResult.lon);
|
const finalUrl = buildWeatherUrl(geoResult.lat, geoResult.lon, weatherSettings);
|
||||||
console.log(`Geocoding completed: ${geoResult.displayName} (${geoResult.lat}, ${geoResult.lon})`);
|
console.log(`Geocoding completed: ${geoResult.displayName} (${geoResult.lat}, ${geoResult.lon})`);
|
||||||
console.log(`Final URL: ${finalUrl}`);
|
console.log(`Final URL: ${finalUrl}`);
|
||||||
return { url: finalUrl, lat: geoResult.lat, lon: geoResult.lon, isLate: true };
|
return { url: finalUrl, lat: geoResult.lat, lon: geoResult.lon, isLate: true };
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.warn(`Geocoding failed: ${err.message}`);
|
console.warn(`Geocoding failed: ${err.message}`);
|
||||||
// Fall back to Toronto if geocoding completely fails
|
// Fallback to Toronto
|
||||||
const fallbackUrl = buildUrl(43.6532, -79.3832);
|
const fallbackUrl = buildWeatherUrl(43.6532, -79.3832, weatherSettings);
|
||||||
return { url: fallbackUrl, lat: 43.6532, lon: -79.3832, isLate: true };
|
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 => result.url);
|
||||||
lateGeocodePromise = geocodePromise.then(result => {
|
|
||||||
return result.url;
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Toronto - use directly
|
// Toronto default
|
||||||
const lat = 43.6532;
|
initialUrl = buildWeatherUrl(43.6532, -79.3832, weatherSettings);
|
||||||
const lon = -79.3832;
|
|
||||||
initialUrl = buildUrl(lat, lon);
|
|
||||||
console.log(`URL: ${initialUrl}`);
|
console.log(`URL: ${initialUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Stream starting: ${city}`);
|
console.log(`Stream starting: ${city}`);
|
||||||
|
|
||||||
// Forward to the main stream endpoint WITH MUSIC
|
// Update request query for stream handler
|
||||||
req.query.url = initialUrl;
|
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 and late geocode promise
|
// Call stream handler with music enabled
|
||||||
return streamHandler(req, res, true, lateGeocodePromise);
|
return streamHandler(req, res, {
|
||||||
|
useMusic: true,
|
||||||
|
musicPath: MUSIC_PATH,
|
||||||
|
lateGeocodePromise
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.send('OK');
|
res.send('OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Webpage to HLS server running on port ${PORT}`);
|
console.log(`Webpage to HLS server running on port ${PORT}`);
|
||||||
console.log(`Usage: http://localhost:${PORT}/stream?url=http://example.com`);
|
console.log(`Usage: http://localhost:${PORT}/stream?url=http://example.com`);
|
||||||
|
|||||||
102
src/ffmpegConfig.js
Normal file
102
src/ffmpegConfig.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
const { createPlaylist } = require('./musicPlaylist');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build FFmpeg arguments for HLS streaming
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {number} options.fps - Frames per second
|
||||||
|
* @param {boolean} options.useMusic - Whether to include audio from music files
|
||||||
|
* @param {string} options.musicPath - Path to music directory
|
||||||
|
* @returns {Promise<{args: string[], playlistFile: string|null, hasMusic: boolean}>}
|
||||||
|
*/
|
||||||
|
async function buildFFmpegArgs({ fps, useMusic, musicPath }) {
|
||||||
|
const ffmpegArgs = [];
|
||||||
|
let playlistFile = null;
|
||||||
|
let hasMusic = false;
|
||||||
|
|
||||||
|
if (useMusic) {
|
||||||
|
const playlistInfo = createPlaylist(musicPath);
|
||||||
|
|
||||||
|
if (playlistInfo) {
|
||||||
|
playlistFile = playlistInfo.playlistFile;
|
||||||
|
hasMusic = true;
|
||||||
|
|
||||||
|
// 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 with audio filtering for smooth transitions
|
||||||
|
ffmpegArgs.push(
|
||||||
|
// Use audio filter to ensure smooth transitions and consistent format
|
||||||
|
'-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0,aformat=sample_rates=44100:channel_layouts=stereo',
|
||||||
|
'-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
|
||||||
|
'-ac', '2', // Stereo output
|
||||||
|
'-avoid_negative_ts', 'make_zero', // Prevent timestamp issues
|
||||||
|
'-fflags', '+genpts+igndts', // Generate presentation timestamps, ignore decode timestamps
|
||||||
|
'-max_interleave_delta', '0', // Reduce audio/video sync issues during transitions
|
||||||
|
'-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 { args: ffmpegArgs, playlistFile, hasMusic };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.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', '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 { args: ffmpegArgs, playlistFile, hasMusic };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildFFmpegArgs };
|
||||||
48
src/geocode.js
Normal file
48
src/geocode.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geocode city to lat/lon using Nominatim (OpenStreetMap)
|
||||||
|
* @param {string} cityQuery - City name to geocode
|
||||||
|
* @returns {Promise<{lat: number, lon: number, displayName: string}>}
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { geocodeCity };
|
||||||
98
src/musicPlaylist.js
Normal file
98
src/musicPlaylist.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a random music file from the music directory
|
||||||
|
* @param {string} musicPath - Path to music directory
|
||||||
|
* @returns {string|null} Path to random music file or null
|
||||||
|
*/
|
||||||
|
function getRandomMusicFile(musicPath) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(musicPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const files = fs.readdirSync(musicPath).filter(f => f.endsWith('.ogg'));
|
||||||
|
if (files.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const randomFile = files[Math.floor(Math.random() * files.length)];
|
||||||
|
return path.join(musicPath, randomFile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting music file:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all music files for playlist generation
|
||||||
|
* @param {string} musicPath - Path to music directory
|
||||||
|
* @returns {string[]} Array of music file paths
|
||||||
|
*/
|
||||||
|
function getAllMusicFiles(musicPath) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(musicPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const files = fs.readdirSync(musicPath).filter(f => f.endsWith('.ogg'));
|
||||||
|
return files.map(f => path.join(musicPath, f));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting music files:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuffle array in place using Fisher-Yates algorithm
|
||||||
|
* @param {Array} array - Array to shuffle
|
||||||
|
* @returns {Array} Shuffled array (same reference)
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a playlist file for FFmpeg concat demuxer
|
||||||
|
* @param {string} musicPath - Path to music directory
|
||||||
|
* @returns {{playlistFile: string, trackCount: number}|null} Playlist info or null
|
||||||
|
*/
|
||||||
|
function createPlaylist(musicPath) {
|
||||||
|
const allMusicFiles = getAllMusicFiles(musicPath);
|
||||||
|
console.log(`Found ${allMusicFiles.length} music files in ${musicPath}`);
|
||||||
|
|
||||||
|
if (allMusicFiles.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary concat playlist file
|
||||||
|
const playlistFile = path.join('/tmp', `playlist-${Date.now()}.txt`);
|
||||||
|
|
||||||
|
// Build playlist content - repeat enough times for ~24 hours of playback
|
||||||
|
// Assuming avg 3 min per track, repeat enough to cover a full day
|
||||||
|
const repetitions = Math.max(20, Math.ceil(480 / allMusicFiles.length)); // At least 480 tracks (~24hrs)
|
||||||
|
const playlistLines = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < repetitions; i++) {
|
||||||
|
// Re-shuffle each repetition for more variety
|
||||||
|
const shuffled = shuffleArray([...allMusicFiles]);
|
||||||
|
shuffled.forEach(f => playlistLines.push(`file '${f}'`));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(playlistFile, playlistLines.join('\n'));
|
||||||
|
console.log(`Created playlist with ${allMusicFiles.length} tracks x${repetitions} repetitions (~${playlistLines.length} total tracks)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
playlistFile,
|
||||||
|
trackCount: playlistLines.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getRandomMusicFile,
|
||||||
|
getAllMusicFiles,
|
||||||
|
shuffleArray,
|
||||||
|
createPlaylist
|
||||||
|
};
|
||||||
76
src/pageLoader.js
Normal file
76
src/pageLoader.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Setup and configure a Puppeteer page
|
||||||
|
* @param {Browser} browser - Puppeteer browser instance
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {number} options.width - Page width
|
||||||
|
* @param {number} options.height - Page height
|
||||||
|
* @returns {Promise<Page>} Configured Puppeteer page
|
||||||
|
*/
|
||||||
|
async function setupPage(browser, { width, height }) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for page to be fully loaded with stylesheet
|
||||||
|
* @param {Page} page - Puppeteer page
|
||||||
|
* @param {string} url - URL to navigate to
|
||||||
|
* @returns {Promise<boolean>} True if page loaded successfully
|
||||||
|
*/
|
||||||
|
async function waitForPageFullyLoaded(page, url) {
|
||||||
|
try {
|
||||||
|
// Wait for DOM content and stylesheet to load
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
console.log('Page DOM loaded, waiting for stylesheet...');
|
||||||
|
|
||||||
|
// Wait a brief moment for stylesheet to apply
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
console.log('Page stylesheet loaded, switching to live frames');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Page load error:', err.message);
|
||||||
|
// Still show the page even if timeout occurs
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide logo elements on the page
|
||||||
|
* @param {Page} page - Puppeteer page
|
||||||
|
*/
|
||||||
|
async function hideLogo(page) {
|
||||||
|
try {
|
||||||
|
await 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
setupPage,
|
||||||
|
waitForPageFullyLoaded,
|
||||||
|
hideLogo
|
||||||
|
};
|
||||||
412
src/streamHandler.js
Normal file
412
src/streamHandler.js
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { buildFFmpegArgs } = require('./ffmpegConfig');
|
||||||
|
const { setupPage, waitForPageFullyLoaded, hideLogo } = require('./pageLoader');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main streaming handler - captures webpage and streams as HLS
|
||||||
|
* @param {Express.Request} req - Express request object
|
||||||
|
* @param {Express.Response} res - Express response object
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {boolean} options.useMusic - Whether to include music
|
||||||
|
* @param {string} options.musicPath - Path to music directory
|
||||||
|
* @param {Promise<string>} options.lateGeocodePromise - Optional promise for late URL update
|
||||||
|
*/
|
||||||
|
async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocodePromise = null }) {
|
||||||
|
const { url, width = 1920, height = 1080, fps = 30, hideLogo: hideLogoFlag = '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;
|
||||||
|
let playlistFile = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set HLS headers
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
|
// Build FFmpeg command and launch browser in parallel
|
||||||
|
const ffmpegConfigPromise = buildFFmpegArgs({
|
||||||
|
fps: parseInt(fps),
|
||||||
|
useMusic,
|
||||||
|
musicPath
|
||||||
|
});
|
||||||
|
|
||||||
|
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`
|
||||||
|
],
|
||||||
|
defaultViewport: { width: parseInt(width), height: parseInt(height), deviceScaleFactor: 1 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for both to complete in parallel
|
||||||
|
const [ffmpegConfig, browserInstance] = await Promise.all([ffmpegConfigPromise, browserPromise]);
|
||||||
|
browser = browserInstance;
|
||||||
|
playlistFile = ffmpegConfig.playlistFile;
|
||||||
|
|
||||||
|
console.log('Starting stream with black frames...');
|
||||||
|
|
||||||
|
// Start FFmpeg immediately
|
||||||
|
ffmpegProcess = spawn('ffmpeg', ['-loglevel', 'error', '-hide_banner', ...ffmpegConfig.args], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pipe FFmpeg output to response
|
||||||
|
ffmpegProcess.stdout.pipe(res);
|
||||||
|
|
||||||
|
ffmpegProcess.stderr.on('data', (data) => {
|
||||||
|
const message = data.toString();
|
||||||
|
// Log important warnings about audio discontinuities or errors
|
||||||
|
if (message.includes('Non-monotonous DTS') ||
|
||||||
|
message.includes('Application provided invalid') ||
|
||||||
|
message.includes('past duration') ||
|
||||||
|
message.includes('Error while decoding stream') ||
|
||||||
|
message.includes('Invalid data found')) {
|
||||||
|
console.warn(`FFmpeg warning: ${message.trim()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpegProcess.on('error', (error) => {
|
||||||
|
console.error('FFmpeg error:', error);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpegProcess.on('close', (code) => {
|
||||||
|
if (code && code !== 0 && !isCleaningUp) {
|
||||||
|
console.error(`FFmpeg exited with code ${code}`);
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpegProcess.stdin.on('error', (error) => {
|
||||||
|
if (error.code !== 'EPIPE') {
|
||||||
|
console.error('FFmpeg stdin error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup Puppeteer page
|
||||||
|
const page = await setupPage(browser, { width: parseInt(width), height: parseInt(height) });
|
||||||
|
|
||||||
|
// Black frame control
|
||||||
|
let sendBlackFrames = true;
|
||||||
|
let waitingForCorrectUrl = !!lateGeocodePromise;
|
||||||
|
|
||||||
|
// Load initial page only if we're not waiting for late geocoding
|
||||||
|
if (!waitingForCorrectUrl) {
|
||||||
|
waitForPageFullyLoaded(page, url)
|
||||||
|
.then(async (loaded) => {
|
||||||
|
if (loaded) {
|
||||||
|
sendBlackFrames = false;
|
||||||
|
if (hideLogoFlag === 'true') {
|
||||||
|
await hideLogo(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Page load promise error:', err.message);
|
||||||
|
sendBlackFrames = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle late geocoding if provided
|
||||||
|
if (lateGeocodePromise) {
|
||||||
|
lateGeocodePromise.then(async (updatedUrl) => {
|
||||||
|
if (!isCleaningUp && page && !page.isClosed() && updatedUrl && updatedUrl !== url) {
|
||||||
|
try {
|
||||||
|
console.log('Updating to correct location...');
|
||||||
|
sendBlackFrames = true;
|
||||||
|
|
||||||
|
await waitForPageFullyLoaded(page, updatedUrl);
|
||||||
|
|
||||||
|
console.log('Correct location fully loaded, switching to live frames');
|
||||||
|
waitingForCorrectUrl = false;
|
||||||
|
sendBlackFrames = false;
|
||||||
|
|
||||||
|
if (hideLogoFlag === 'true') {
|
||||||
|
await hideLogo(page);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Location update error:', err.message);
|
||||||
|
waitingForCorrectUrl = false;
|
||||||
|
sendBlackFrames = false;
|
||||||
|
}
|
||||||
|
} else if (!updatedUrl || updatedUrl === url) {
|
||||||
|
console.log('Using initial URL, waiting for page load to complete...');
|
||||||
|
// URL is the same, so load it now
|
||||||
|
try {
|
||||||
|
await waitForPageFullyLoaded(page, url);
|
||||||
|
waitingForCorrectUrl = false;
|
||||||
|
sendBlackFrames = false;
|
||||||
|
if (hideLogoFlag === 'true') {
|
||||||
|
await hideLogo(page);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Initial page load error:', err.message);
|
||||||
|
waitingForCorrectUrl = false;
|
||||||
|
sendBlackFrames = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
console.warn('Geocoding failed, waiting for fallback location to load');
|
||||||
|
// Load the fallback URL
|
||||||
|
waitForPageFullyLoaded(page, url)
|
||||||
|
.then(async () => {
|
||||||
|
waitingForCorrectUrl = false;
|
||||||
|
sendBlackFrames = false;
|
||||||
|
if (hideLogoFlag === 'true') {
|
||||||
|
await hideLogo(page);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
waitingForCorrectUrl = false;
|
||||||
|
sendBlackFrames = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic page refresh
|
||||||
|
const pageRefreshInterval = setInterval(async () => {
|
||||||
|
if (!isCleaningUp && page && !page.isClosed()) {
|
||||||
|
try {
|
||||||
|
console.log('Refreshing page for stability...');
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||||
|
if (hideLogoFlag === 'true') {
|
||||||
|
await hideLogo(page);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Page refresh error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30 * 60 * 1000);
|
||||||
|
|
||||||
|
// Frame capture
|
||||||
|
const frameInterval = 1000 / fps;
|
||||||
|
let captureLoopActive = true;
|
||||||
|
let consecutiveErrors = 0;
|
||||||
|
const MAX_CONSECUTIVE_ERRORS = 5;
|
||||||
|
|
||||||
|
const createBlackFrame = () => {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
return Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let blackFrameBuffer = null;
|
||||||
|
|
||||||
|
const captureLoop = async () => {
|
||||||
|
while (!isCleaningUp && captureLoopActive && ffmpegProcess && !ffmpegProcess.killed) {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
if (page.isClosed()) {
|
||||||
|
console.error('Page was closed unexpectedly');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let screenshot;
|
||||||
|
|
||||||
|
if (sendBlackFrames) {
|
||||||
|
if (!blackFrameBuffer) {
|
||||||
|
blackFrameBuffer = createBlackFrame();
|
||||||
|
}
|
||||||
|
screenshot = blackFrameBuffer;
|
||||||
|
} else {
|
||||||
|
screenshot = await page.screenshot({
|
||||||
|
type: 'jpeg',
|
||||||
|
quality: 80,
|
||||||
|
optimizeForSpeed: true,
|
||||||
|
fromSurface: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenshot && screenshot.length > 0 && ffmpegProcess && ffmpegProcess.stdin.writable && !isCleaningUp) {
|
||||||
|
const canWrite = ffmpegProcess.stdin.write(screenshot);
|
||||||
|
if (!canWrite) {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
let resolved = false;
|
||||||
|
const currentProcess = ffmpegProcess;
|
||||||
|
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);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
consecutiveErrors = 0;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCleaningUp) {
|
||||||
|
consecutiveErrors++;
|
||||||
|
console.error(`Capture error (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, error.message || error);
|
||||||
|
|
||||||
|
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||||
|
console.error('Too many consecutive errors, stopping stream');
|
||||||
|
try { await cleanup(); } catch (e) {}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
const wait = Math.max(0, frameInterval - elapsed);
|
||||||
|
if (wait > 0) await new Promise(r => setTimeout(r, wait));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
captureLoop();
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
const cleanup = async () => {
|
||||||
|
if (isCleaningUp) return;
|
||||||
|
isCleaningUp = true;
|
||||||
|
console.log('Cleaning up stream...');
|
||||||
|
|
||||||
|
try { captureLoopActive = false; } catch (e) {}
|
||||||
|
try { clearInterval(pageRefreshInterval); } catch (e) {}
|
||||||
|
|
||||||
|
if (ffmpegProcess && !ffmpegProcess.killed) {
|
||||||
|
try {
|
||||||
|
ffmpegProcess.stdin.end();
|
||||||
|
} catch (err) {}
|
||||||
|
ffmpegProcess.kill('SIGTERM');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ffmpegProcess && !ffmpegProcess.killed) {
|
||||||
|
ffmpegProcess.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
ffmpegProcess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlistFile) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(playlistFile);
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
try {
|
||||||
|
await browser.close();
|
||||||
|
} catch (err) {}
|
||||||
|
browser = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.headersSent && !res.writableEnded) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle client disconnect
|
||||||
|
let disconnectLogged = false;
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
if (!disconnectLogged) {
|
||||||
|
console.log('Client disconnected');
|
||||||
|
disconnectLogged = true;
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
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) => {
|
||||||
|
if (error.code === 'ECONNRESET' || error.code === 'EPIPE') {
|
||||||
|
if (!disconnectLogged) {
|
||||||
|
console.log('Client disconnected');
|
||||||
|
disconnectLogged = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Response error:', error);
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keepalive monitoring
|
||||||
|
const keepaliveInterval = setInterval(() => {
|
||||||
|
if (isCleaningUp || !ffmpegProcess || ffmpegProcess.killed) {
|
||||||
|
clearInterval(keepaliveInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.writableEnded || res.socket?.destroyed) {
|
||||||
|
console.log('Connection lost, cleaning up');
|
||||||
|
clearInterval(keepaliveInterval);
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
} 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { streamHandler };
|
||||||
Reference in New Issue
Block a user