diff --git a/index.js b/index.js index b7e3e7b..9aaf5a9 100644 --- a/index.js +++ b/index.js @@ -141,13 +141,16 @@ app.get('/weather', async (req, res) => { timeFormat }; + // Generate session ID for logging + const sessionId = Math.floor(Math.random() * 100000); + let lateGeocodePromise = null; let geocodeDataPromise = null; let initialUrl = 'data:text/html,'; if (city && city !== 'Toronto, ON, CAN') { // Start geocoding in background - don't wait for it - const geocodePromise = geocodeCity(city); + const geocodePromise = geocodeCity(city, sessionId); geocodeDataPromise = geocodePromise; // Save for city name overlay // Always start with black screen immediately @@ -179,6 +182,10 @@ app.get('/weather', async (req, res) => { // Call stream handler with music enabled const { debug = DEBUG_MODE ? 'true' : 'false' } = req.query; + + // Build request path for logging + const requestPath = `/weather?city=${encodeURIComponent(city)}`; + return streamHandler(req, res, { useMusic: true, musicPath: MUSIC_PATH, @@ -188,7 +195,9 @@ app.get('/weather', async (req, res) => { defaultFps: DEFAULT_FPS, screenshotFormat: SCREENSHOT_FORMAT, screenshotQuality: SCREENSHOT_QUALITY, - debugMode: debug === 'true' + debugMode: debug === 'true', + sessionId, + requestPath }); }); diff --git a/src/ffmpegConfig.js b/src/ffmpegConfig.js index 5aa5234..9f84d97 100644 --- a/src/ffmpegConfig.js +++ b/src/ffmpegConfig.js @@ -12,9 +12,10 @@ const { createPlaylist } = require('./musicPlaylist'); * @param {string} options.cityName - City name to display (optional) * @param {number} options.videoWidth - Video width for centering text * @param {number} options.videoHeight - Video height for scaling text + * @param {string} options.sessionId - Session ID for logging (optional) * @returns {Promise<{args: string[], playlistFile: string|null, hasMusic: boolean, captureFps: number}>} */ -async function buildFFmpegArgs({ fps, useMusic, musicPath, inputFormat = 'jpeg', captureAtHigherFps = false, debugMode = false, cityName = null, videoWidth = 1920, videoHeight = 1080 }) { +async function buildFFmpegArgs({ fps, useMusic, musicPath, inputFormat = 'jpeg', captureAtHigherFps = false, debugMode = false, cityName = null, videoWidth = 1920, videoHeight = 1080, sessionId = null }) { const captureFps = captureAtHigherFps ? fps * 2 : fps; // Scale text sizes and positions based on video height (1080p as reference) @@ -41,7 +42,7 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath, inputFormat = 'jpeg', let hasMusic = false; if (useMusic) { - const playlistInfo = createPlaylist(musicPath); + const playlistInfo = createPlaylist(musicPath, sessionId); if (playlistInfo) { playlistFile = playlistInfo.playlistFile; @@ -90,7 +91,7 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath, inputFormat = 'jpeg', ...(captureAtHigherFps ? ['-r', fps.toString()] : []), '-tune', 'zerolatency', '-pix_fmt', 'yuv420p', - '-g', fps.toString(), // Keyframe every 1 second for 1s segments + '-g', (fps * 8).toString(), // Keyframe every 8 seconds for 8s 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 @@ -109,7 +110,7 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath, inputFormat = 'jpeg', '-max_interleave_delta', '500000', // Increased for smoother transitions (500ms) '-err_detect', 'ignore_err', // Continue on minor audio errors '-f', 'hls', - '-hls_time', '1', // 1-second segments for faster startup + '-hls_time', '8', // 8-second segments (standard HLS) '-hls_list_size', '3', // Minimal segments for faster startup '-hls_flags', 'omit_endlist+program_date_time+independent_segments', '-hls_segment_type', 'mpegts', @@ -141,14 +142,14 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath, inputFormat = 'jpeg', ...(captureAtHigherFps ? ['-r', fps.toString()] : []), '-tune', 'zerolatency', '-pix_fmt', 'yuv420p', - '-g', fps.toString(), // Keyframe every 1 second for 1s segments + '-g', (fps * 8).toString(), // Keyframe every 8 seconds for 8s segments '-bf', '0', '-x264opts', 'nal-hrd=cbr:no-scenecut', '-b:v', '2500k', '-maxrate', '2500k', '-bufsize', '5000k', '-f', 'hls', - '-hls_time', '1', // 1-second segments for faster startup + '-hls_time', '8', // 8-second segments (standard HLS) '-hls_list_size', '3', // Minimal segments for faster startup '-hls_flags', 'omit_endlist+program_date_time+independent_segments', '-hls_segment_type', 'mpegts', diff --git a/src/geocode.js b/src/geocode.js index c0dd988..3199680 100644 --- a/src/geocode.js +++ b/src/geocode.js @@ -64,11 +64,13 @@ function saveCache(cache) { } /** - * Get cached geocode data if available + * Get cached geocode data if available (synchronous for immediate response) * @param {string} cityQuery - City name + * @param {string} sessionId - Optional session ID for logging * @returns {Object|null} Cached data or null */ -function getCachedGeocode(cityQuery) { +function getCachedGeocodeSync(cityQuery, sessionId = null) { + const prefix = sessionId ? `[${sessionId}] ` : ''; try { const cache = loadCache(); const normalized = normalizeQuery(cityQuery); @@ -77,7 +79,7 @@ function getCachedGeocode(cityQuery) { const locationKey = cache.queries[normalized]; if (locationKey && cache.locations[locationKey]) { const location = cache.locations[locationKey]; - console.log(`Geocode: ${cityQuery} -> ${location.cityName} (cached)`); + console.log(`${prefix}Geocode: ${cityQuery} -> ${location.cityName} (cached)`); return { ...location, query: cityQuery // Return with original query @@ -143,69 +145,80 @@ function saveCachedGeocode(cityQuery, data) { /** * Geocode city to lat/lon using Nominatim (OpenStreetMap) * @param {string} cityQuery - City name to geocode + * @param {string} sessionId - Optional session ID for logging * @returns {Promise<{lat: number, lon: number, displayName: string}>} */ -async function geocodeCity(cityQuery) { - // Check cache first - const cached = getCachedGeocode(cityQuery); - if (cached) { - return cached; - } +async function geocodeCity(cityQuery, sessionId = null) { + const prefix = sessionId ? `[${sessionId}] ` : ''; + // Check cache first - return immediately if found, don't block return new Promise((resolve, reject) => { - const encodedQuery = encodeURIComponent(cityQuery); - const url = `https://nominatim.openstreetmap.org/search?q=${encodedQuery}&format=json&limit=1&addressdetails=1`; - - const options = { - headers: { - 'User-Agent': 'webpage-to-hls-streaming-app/1.0' + // Check cache in nextTick to avoid blocking the event loop + setImmediate(() => { + const cached = getCachedGeocodeSync(cityQuery, sessionId); + if (cached) { + return resolve(cached); } - }; - - 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) { - const result = results[0]; - const address = result.address || {}; - - // Extract clean city name - prefer city, town, village, municipality, or the name field - const cityName = address.city || address.town || address.village || - address.municipality || address.hamlet || result.name || - cityQuery.split(',')[0].trim(); - - const geocodeResult = { - query: cityQuery, - lat: parseFloat(result.lat), - lon: parseFloat(result.lon), - displayName: result.display_name, - cityName: cityName, - state: address.state || null, - stateDistrict: address.state_district || null, - county: address.county || null, - country: address.country || null, - countryCode: address.country_code ? address.country_code.toUpperCase() : null - }; - console.log(`Geocode: ${cityQuery} -> ${geocodeResult.cityName}, ${geocodeResult.state || geocodeResult.country} (API)`); - // Save to cache - saveCachedGeocode(cityQuery, geocodeResult); - resolve(geocodeResult); - } else { - reject(new Error('No results found')); - } - } catch (error) { - reject(error); - } - }); - }).on('error', (error) => { - reject(error); + // Not in cache, fetch from API + fetchFromAPI(); }); + + function fetchFromAPI() { + const encodedQuery = encodeURIComponent(cityQuery); + const url = `https://nominatim.openstreetmap.org/search?q=${encodedQuery}&format=json&limit=1&addressdetails=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) { + const result = results[0]; + const address = result.address || {}; + + // Extract clean city name - prefer city, town, village, municipality, or the name field + const cityName = address.city || address.town || address.village || + address.municipality || address.hamlet || result.name || + cityQuery.split(',')[0].trim(); + + const geocodeResult = { + query: cityQuery, + lat: parseFloat(result.lat), + lon: parseFloat(result.lon), + displayName: result.display_name, + cityName: cityName, + state: address.state || null, + stateDistrict: address.state_district || null, + county: address.county || null, + country: address.country || null, + countryCode: address.country_code ? address.country_code.toUpperCase() : null + }; + console.log(`${prefix}Geocode: ${cityQuery} -> ${geocodeResult.cityName}, ${geocodeResult.state || geocodeResult.country} (API)`); + // Save to cache + saveCachedGeocode(cityQuery, geocodeResult); + resolve(geocodeResult); + } else { + reject(new Error('No results found')); + } + } catch (error) { + reject(error); + } + }); + }).on('error', (error) => { + reject(error); + }); + } }); } diff --git a/src/musicPlaylist.js b/src/musicPlaylist.js index bbce4a5..628baed 100644 --- a/src/musicPlaylist.js +++ b/src/musicPlaylist.js @@ -173,9 +173,11 @@ function initializeSharedPlaylist(musicPath) { /** * Create a rotated playlist starting at a random position * @param {string} musicPath - Path to music directory + * @param {string} sessionId - Optional session ID for logging * @returns {{playlistFile: string, trackCount: number}|null} Playlist info or null */ -function createPlaylist(musicPath) { +function createPlaylist(musicPath, sessionId = null) { + const prefix = sessionId ? `[${sessionId}] ` : ''; // Check if we have a shared playlist if (!sharedPlaylistFile || !fs.existsSync(sharedPlaylistFile)) { console.warn('Warning: Shared playlist not found, initializing...'); @@ -219,7 +221,7 @@ function createPlaylist(musicPath) { } fs.writeFileSync(playlistFile, playlistLines.join('\n')); - console.log(`Stream playlist created, starting at position ${randomStart}/${allMusicFiles.length}`); + console.log(`${prefix}Stream playlist created, starting at position ${randomStart}/${allMusicFiles.length}`); return { playlistFile, diff --git a/src/streamHandler.js b/src/streamHandler.js index f238bf5..6f801b4 100644 --- a/src/streamHandler.js +++ b/src/streamHandler.js @@ -19,7 +19,7 @@ const { setupPage, waitForPageFullyLoaded, hideLogo, startAutoScroll } = require * @param {number} options.screenshotQuality - JPEG quality (1-100) * @param {boolean} options.debugMode - Whether to show debug stats overlay */ -async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocodePromise = null, geocodeDataPromise = null, startTime = Date.now(), defaultFps = 30, screenshotFormat = 'jpeg', screenshotQuality = 95, debugMode = false }) { +async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocodePromise = null, geocodeDataPromise = null, startTime = Date.now(), defaultFps = 30, screenshotFormat = 'jpeg', screenshotQuality = 95, debugMode = false, sessionId: providedSessionId = null, requestPath = null }) { const { url, width = 1920, height = 1080, fps: fpsParam, hideLogo: hideLogoFlag = 'false', refreshInterval = 90, scroll = 'false', scrollPause = 30 } = req.query; // Parse numeric parameters - use query param if provided, otherwise use default from env @@ -36,12 +36,19 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod return res.status(400).send('Invalid URL'); } + // Use provided session ID or generate a new one + const sessionId = providedSessionId || Math.floor(Math.random() * 100000); + let streamId = `[${sessionId}]`; + + // Log the actual request path if provided, otherwise the URL + const logUrl = requestPath || url; + console.log(`${streamId} New stream request: ${logUrl}`); + let browser = null; let ffmpegProcess = null; let isCleaningUp = false; let playlistFile = null; let cleanup; // Declare cleanup function variable early - let streamId = '[STREAM]'; // Default stream ID let stopAutoScroll = null; // Function to stop auto-scrolling try { @@ -53,7 +60,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod // Build FFmpeg command and launch browser in parallel const useScroll = scroll === 'true'; - // Get city name if geocode data is available + // Get city name from geocoding - wait for it to ensure overlay is correct let cityName = null; if (geocodeDataPromise) { try { @@ -71,9 +78,10 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod inputFormat: screenshotFormat, captureAtHigherFps: useScroll, // Capture at 2x FPS when scrolling for smoother output debugMode, - cityName, + cityName, // Will be null if geocoding takes >50ms videoWidth: parseInt(width), - videoHeight: parseInt(height) + videoHeight: parseInt(height), + sessionId }); const browserPromise = puppeteer.launch({ @@ -117,9 +125,6 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod browser = browserInstance; playlistFile = ffmpegConfig.playlistFile; const captureFps = ffmpegConfig.captureFps || fps; // Use capture FPS (may be 2x target FPS) - - // Update stream identifier from browser process PID - streamId = `[${browser.process()?.pid || 'STREAM'}]`; // Start FFmpeg immediately ffmpegProcess = spawn('ffmpeg', ['-loglevel', 'error', '-hide_banner', ...ffmpegConfig.args], { @@ -128,6 +133,55 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod // Pipe FFmpeg output to response ffmpegProcess.stdout.pipe(res); + + // Log HLS stream ready time + const hlsElapsed = ((Date.now() - startTime) / 1000).toFixed(2); + console.log(`${streamId} HLS stream ready (${hlsElapsed}s)`); + + // Pre-fill buffer with black frames to create initial segments instantly + // This allows clients to start playback immediately (10 seconds worth) + const preFillSeconds = 10; + const preFillFrames = fps * preFillSeconds; + const preBlackFrame = (() => { + 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)); + if (screenshotFormat === 'png') { + return canvasObj.toBuffer('image/png'); + } else { + return canvasObj.toBuffer('image/jpeg', { quality: 0.5 }); + } + } catch (err) { + return null; + } + })(); + + // Write frames asynchronously to avoid blocking + if (preBlackFrame && preBlackFrame.length > 0 && ffmpegProcess && ffmpegProcess.stdin.writable) { + let framesPushed = 0; + const pushFrame = () => { + if (!ffmpegProcess || !ffmpegProcess.stdin.writable || framesPushed >= preFillFrames) { + return; + } + + while (framesPushed < preFillFrames) { + const canWrite = ffmpegProcess.stdin.write(preBlackFrame); + framesPushed++; + + if (!canWrite) { + // Wait for drain before continuing + ffmpegProcess.stdin.once('drain', pushFrame); + return; + } + } + }; + + // Start pushing frames immediately + setImmediate(pushFrame); + } ffmpegProcess.stderr.on('data', (data) => { const message = data.toString(); @@ -198,7 +252,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod .then(async (loaded) => { if (loaded) { const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); - console.log(`${streamId} Stream ready (${elapsed}s)`); + console.log(`${streamId} Page fully loaded (${elapsed}s)`); sendBlackFrames = false; if (hideLogoFlag === 'true') { await hideLogo(page); @@ -224,7 +278,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod await waitForPageFullyLoaded(page, updatedUrl); const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); - console.log(`${streamId} Stream ready (${elapsed}s)`); + console.log(`${streamId} Page fully loaded (${elapsed}s)`); waitingForCorrectUrl = false; sendBlackFrames = false; @@ -244,7 +298,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod try { await waitForPageFullyLoaded(page, url); const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); - console.log(`${streamId} Stream ready (${elapsed}s)`); + console.log(`${streamId} Page fully loaded (${elapsed}s)`); waitingForCorrectUrl = false; sendBlackFrames = false; if (hideLogoFlag === 'true') { @@ -264,7 +318,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod waitForPageFullyLoaded(page, url) .then(async () => { const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); - console.log(`${streamId} Stream ready (${elapsed}s)`); + console.log(`${streamId} Page fully loaded (${elapsed}s)`); waitingForCorrectUrl = false; sendBlackFrames = false; if (hideLogoFlag === 'true') {