diff --git a/Dockerfile b/Dockerfile index 5cfd25d..2efa03a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,4 +67,4 @@ COPY src/ ./src/ EXPOSE 3000 # Start both services using JSON array format -CMD ["/bin/sh", "-c", "cd /app && node index.js & cd /streaming-app && yarn start"] +CMD ["/bin/sh", "-c", "cd /app && node index.js > /dev/null 2>&1 & cd /streaming-app && yarn start"] diff --git a/index.js b/index.js index 2dcc706..bc23672 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,7 @@ const express = require('express'); const { streamHandler } = require('./src/streamHandler'); const { geocodeCity } = require('./src/geocode'); -const { getAllMusicFiles } = require('./src/musicPlaylist'); +const { getAllMusicFiles, initializeSharedPlaylist } = require('./src/musicPlaylist'); const app = express(); const PORT = process.env.PORT || 3000; @@ -79,9 +79,11 @@ function buildWeatherUrl(latitude, longitude, settings) { // Basic stream endpoint (no music) app.get('/stream', (req, res) => { + const startTime = Date.now(); streamHandler(req, res, { useMusic: false, - musicPath: MUSIC_PATH + musicPath: MUSIC_PATH, + startTime }); }); @@ -133,38 +135,38 @@ app.get('/weather', async (req, res) => { let initialUrl = 'data:text/html,'; if (city && city !== 'Toronto, ON, CAN') { - // Try quick geocode first - const geocodePromise = Promise.race([ - geocodeCity(city), + // Start geocoding (only call once) + const geocodePromise = geocodeCity(city); + + // Try to use quick result if available within 1 second + const quickResult = Promise.race([ + geocodePromise, new Promise((_, reject) => setTimeout(() => reject(new Error('Geocoding timeout')), 1000)) - ]).then(geoResult => { - console.log(`Geocoded: ${city} -> ${geoResult.displayName}`); - const finalUrl = buildWeatherUrl(geoResult.lat, geoResult.lon, weatherSettings); - console.log(`URL: ${finalUrl}`); - return { url: finalUrl, lat: geoResult.lat, lon: geoResult.lon }; - }).catch(error => { - // Continue geocoding in background - return geocodeCity(city).then(geoResult => { - const finalUrl = buildWeatherUrl(geoResult.lat, geoResult.lon, weatherSettings); - console.log(`Geocoding completed: ${geoResult.displayName} (${geoResult.lat}, ${geoResult.lon})`); - console.log(`Final URL: ${finalUrl}`); - return { url: finalUrl, lat: geoResult.lat, lon: geoResult.lon, isLate: true }; - }).catch(err => { - console.warn(`Geocoding failed: ${err.message}`); - // Fallback to Toronto - const fallbackUrl = buildWeatherUrl(43.6532, -79.3832, weatherSettings); - return { url: fallbackUrl, lat: 43.6532, lon: -79.3832, isLate: true }; - }); + ]).catch(() => null); // Timeout = null, will use late result + + // Build URL from quick result if available + const urlPromise = quickResult.then(geoResult => { + if (geoResult) { + // Got quick result + return buildWeatherUrl(geoResult.lat, geoResult.lon, weatherSettings); + } + return null; // Will use initial black screen + }); + + // Late geocode promise (reuses the same geocode call) + lateGeocodePromise = geocodePromise.then(geoResult => { + return buildWeatherUrl(geoResult.lat, geoResult.lon, weatherSettings); + }).catch(err => { + console.warn(`Geocoding failed for ${city}, using fallback`); + // Fallback to Toronto + return buildWeatherUrl(43.6532, -79.3832, weatherSettings); }); - - lateGeocodePromise = geocodePromise.then(result => result.url); } else { // Toronto default initialUrl = buildWeatherUrl(43.6532, -79.3832, weatherSettings); - console.log(`URL: ${initialUrl}`); } - console.log(`Stream starting: ${city}`); + const startTime = Date.now(); // Update request query for stream handler req.query.url = initialUrl; @@ -177,7 +179,8 @@ app.get('/weather', async (req, res) => { return streamHandler(req, res, { useMusic: true, musicPath: MUSIC_PATH, - lateGeocodePromise + lateGeocodePromise, + startTime }); }); @@ -189,18 +192,23 @@ app.get('/health', (req, res) => { // Start server app.listen(PORT, () => { console.log(`Webpage to HLS server running on port ${PORT}`); - console.log(`WS4KP weather service on port ${WS4KP_PORT}`); + if (process.env.WS4KP_EXTERNAL_PORT) { + console.log(`WS4KP weather service on port ${process.env.WS4KP_EXTERNAL_PORT}`); + } console.log(`Usage: http://localhost:${PORT}/stream?url=http://example.com`); console.log(`Weather: http://localhost:${PORT}/weather?city=YourCity`); // Pre-validate music files on startup to cache results if (MUSIC_PATH) { - console.log(`\n🎵 Pre-validating music library at ${MUSIC_PATH}...`); + console.log(`\nInitializing music library at ${MUSIC_PATH}...`); const validFiles = getAllMusicFiles(MUSIC_PATH); if (validFiles.length > 0) { - console.log(`✅ Music library ready: ${validFiles.length} valid tracks cached\n`); + console.log(`Music library ready: ${validFiles.length} tracks validated`); + // Initialize shared playlist at startup + initializeSharedPlaylist(MUSIC_PATH); + console.log(''); } else { - console.log(`⚠️ No valid music files found in ${MUSIC_PATH}\n`); + console.log(`Warning: No valid music files found in ${MUSIC_PATH}\n`); } } }); diff --git a/src/ffmpegConfig.js b/src/ffmpegConfig.js index 07114d6..32635d6 100644 --- a/src/ffmpegConfig.js +++ b/src/ffmpegConfig.js @@ -29,6 +29,7 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath }) { ); // Input 1: audio from concat playlist with smoother demuxing + // Note: playlist is pre-shuffled, so each stream gets unique music ffmpegArgs.push( '-f', 'concat', '-safe', '0', diff --git a/src/geocode.js b/src/geocode.js index a0711ce..de5f744 100644 --- a/src/geocode.js +++ b/src/geocode.js @@ -33,12 +33,12 @@ function getCachedGeocode(cityQuery) { const cached = JSON.parse(data); // Verify the query matches if (cached.query && cached.query.toLowerCase().trim() === cityQuery.toLowerCase().trim()) { - console.log(`Using cached geocode for: ${cityQuery}`); + console.log(`Geocode: ${cityQuery} (cached)`); return cached; } } } catch (error) { - console.warn(`Cache read error for ${cityQuery}:`, error.message); + // Silent fail } return null; } @@ -52,9 +52,8 @@ function saveCachedGeocode(cityQuery, data) { try { const cacheFile = path.join(CACHE_DIR, getCacheFileName(cityQuery)); fs.writeFileSync(cacheFile, JSON.stringify(data, null, 2), 'utf8'); - console.log(`Cached geocode for: ${cityQuery}`); } catch (error) { - console.warn(`Cache write error for ${cityQuery}:`, error.message); + // Silent fail } } @@ -96,6 +95,7 @@ async function geocodeCity(cityQuery) { lon: parseFloat(results[0].lon), displayName: results[0].display_name }; + console.log(`Geocode: ${cityQuery} -> ${geocodeResult.displayName} (API)`); // Save to cache saveCachedGeocode(cityQuery, geocodeResult); resolve(geocodeResult); diff --git a/src/musicPlaylist.js b/src/musicPlaylist.js index 1c0198a..bbce4a5 100644 --- a/src/musicPlaylist.js +++ b/src/musicPlaylist.js @@ -6,6 +6,11 @@ const { execSync } = require('child_process'); let validationCache = null; let cacheForPath = null; +// Global shared playlist cache +let sharedPlaylistFile = null; +let sharedPlaylistTrackCount = 0; +let sharedPlaylistPath = null; + /** * Validate an audio file using ffprobe * @param {string} filePath - Path to audio file @@ -23,7 +28,6 @@ function validateAudioFile(filePath) { // Check if audio stream exists and has valid properties if (!data.streams || data.streams.length === 0) { - console.warn(`⚠️ Skipping ${path.basename(filePath)}: No audio stream found`); return false; } @@ -31,27 +35,23 @@ function validateAudioFile(filePath) { // Validate codec if (!stream.codec_name) { - console.warn(`⚠️ Skipping ${path.basename(filePath)}: Unknown codec`); return false; } // Validate sample rate (should be reasonable) const sampleRate = parseInt(stream.sample_rate); if (!sampleRate || sampleRate < 8000 || sampleRate > 192000) { - console.warn(`⚠️ Skipping ${path.basename(filePath)}: Invalid sample rate (${sampleRate})`); return false; } // Validate channels const channels = parseInt(stream.channels); if (!channels || channels < 1 || channels > 8) { - console.warn(`⚠️ Skipping ${path.basename(filePath)}: Invalid channel count (${channels})`); return false; } return true; } catch (error) { - console.warn(`⚠️ Skipping ${path.basename(filePath)}: Validation failed (${error.message})`); return false; } } @@ -91,28 +91,24 @@ function getAllMusicFiles(musicPath) { // Return cached results if available for this path if (validationCache && cacheForPath === musicPath) { - console.log(`✅ Using cached validation results: ${validationCache.length} files`); return validationCache; } const files = fs.readdirSync(musicPath).filter(f => f.endsWith('.ogg')); const filePaths = files.map(f => path.join(musicPath, f)); - console.log(`🎵 Validating ${filePaths.length} audio files (first run, this will be cached)...`); + console.log(`Validating ${filePaths.length} audio files...`); // Validate each file const validFiles = filePaths.filter(validateAudioFile); - console.log(`✅ ${validFiles.length}/${filePaths.length} audio files passed validation`); - if (validFiles.length < filePaths.length) { - console.log(`⚠️ ${filePaths.length - validFiles.length} files were skipped due to issues`); + console.log(`Warning: ${filePaths.length - validFiles.length} files skipped due to validation errors`); } // Cache the results validationCache = validFiles; cacheForPath = musicPath; - console.log(`💾 Validation results cached for future requests`); return validFiles; } catch (error) { @@ -135,43 +131,95 @@ function shuffleArray(array) { } /** - * Create a playlist file for FFmpeg concat demuxer with optimized format + * Initialize shared playlist at startup * @param {string} musicPath - Path to music directory - * @returns {{playlistFile: string, trackCount: number}|null} Playlist info or null */ -function createPlaylist(musicPath) { +function initializeSharedPlaylist(musicPath) { const allMusicFiles = getAllMusicFiles(musicPath); - console.log(`Found ${allMusicFiles.length} validated music files`); if (allMusicFiles.length === 0) { - return null; + return; } - // Create a temporary concat playlist file - const playlistFile = path.join('/tmp', `playlist-${Date.now()}.txt`); + // Create a persistent shared playlist file + const playlistFile = path.join('/tmp', `shared-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 repetitions = Math.max(20, Math.ceil(480 / allMusicFiles.length)); const playlistLines = []; - // Use FFmpeg concat demuxer format with improved options for smooth transitions playlistLines.push('ffconcat version 1.0'); for (let i = 0; i < repetitions; i++) { - // Re-shuffle each repetition for more variety const shuffled = shuffleArray([...allMusicFiles]); shuffled.forEach(f => { - // Escape single quotes in file paths for concat format const escapedPath = f.replace(/'/g, "'\\''"); playlistLines.push(`file '${escapedPath}'`); - // Add duration metadata hint for smoother transitions (helps FFmpeg prebuffer) playlistLines.push('# Auto-generated entry'); }); } fs.writeFileSync(playlistFile, playlistLines.join('\n')); - console.log(`✅ Created seamless playlist: ${allMusicFiles.length} tracks × ${repetitions} repetitions = ${allMusicFiles.length * repetitions} total tracks`); + const totalTracks = allMusicFiles.length * repetitions; + + // Cache the shared playlist + sharedPlaylistFile = playlistFile; + sharedPlaylistTrackCount = totalTracks; + sharedPlaylistPath = musicPath; + + console.log(`Shared playlist created: ${totalTracks} tracks`); +} + +/** + * Create a rotated playlist starting at a random position + * @param {string} musicPath - Path to music directory + * @returns {{playlistFile: string, trackCount: number}|null} Playlist info or null + */ +function createPlaylist(musicPath) { + // Check if we have a shared playlist + if (!sharedPlaylistFile || !fs.existsSync(sharedPlaylistFile)) { + console.warn('Warning: Shared playlist not found, initializing...'); + initializeSharedPlaylist(musicPath); + if (!sharedPlaylistFile) { + return null; + } + } + + const allMusicFiles = getAllMusicFiles(musicPath); + if (allMusicFiles.length === 0) { + return null; + } + + // Create a rotated copy of the playlist with random start + const playlistFile = path.join('/tmp', `playlist-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.txt`); + const randomStart = Math.floor(Math.random() * allMusicFiles.length); + + const repetitions = Math.max(20, Math.ceil(480 / allMusicFiles.length)); + const playlistLines = []; + + playlistLines.push('ffconcat version 1.0'); + + // First repetition with rotation + const firstShuffle = shuffleArray([...allMusicFiles]); + for (let i = 0; i < firstShuffle.length; i++) { + const idx = (randomStart + i) % firstShuffle.length; + const escapedPath = firstShuffle[idx].replace(/'/g, "'\\''"); + playlistLines.push(`file '${escapedPath}'`); + playlistLines.push('# Auto-generated entry'); + } + + // Remaining repetitions + for (let i = 1; i < repetitions; i++) { + const shuffled = shuffleArray([...allMusicFiles]); + shuffled.forEach(f => { + const escapedPath = f.replace(/'/g, "'\\''"); + playlistLines.push(`file '${escapedPath}'`); + playlistLines.push('# Auto-generated entry'); + }); + } + + fs.writeFileSync(playlistFile, playlistLines.join('\n')); + console.log(`Stream playlist created, starting at position ${randomStart}/${allMusicFiles.length}`); return { playlistFile, @@ -183,5 +231,6 @@ module.exports = { getRandomMusicFile, getAllMusicFiles, shuffleArray, - createPlaylist + createPlaylist, + initializeSharedPlaylist }; diff --git a/src/pageLoader.js b/src/pageLoader.js index 6c85221..39a16af 100644 --- a/src/pageLoader.js +++ b/src/pageLoader.js @@ -36,12 +36,10 @@ 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); @@ -65,7 +63,7 @@ async function hideLogo(page) { }); }); } catch (err) { - console.error('Logo hide error:', err); + // Silent } } diff --git a/src/streamHandler.js b/src/streamHandler.js index a82b960..16fb56a 100644 --- a/src/streamHandler.js +++ b/src/streamHandler.js @@ -12,8 +12,9 @@ const { setupPage, waitForPageFullyLoaded, hideLogo } = require('./pageLoader'); * @param {boolean} options.useMusic - Whether to include music * @param {string} options.musicPath - Path to music directory * @param {Promise} options.lateGeocodePromise - Optional promise for late URL update + * @param {number} options.startTime - Request start timestamp */ -async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocodePromise = null }) { +async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocodePromise = null, startTime = Date.now() }) { const { url, width = 1920, height = 1080, fps = 30, hideLogo: hideLogoFlag = 'false', refreshInterval = 90 } = req.query; if (!url) { @@ -75,14 +76,13 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod browser = browserInstance; playlistFile = ffmpegConfig.playlistFile; - console.log('Starting stream with black frames...'); + // Create stream identifier from browser process PID + const streamId = `[${browser.process()?.pid || 'STREAM'}]`; // Start FFmpeg immediately ffmpegProcess = spawn('ffmpeg', ['-loglevel', 'error', '-hide_banner', ...ffmpegConfig.args], { stdio: ['pipe', 'pipe', 'pipe'] }); - - console.log('FFmpeg started with args:', ffmpegConfig.args.slice(0, 20).join(' ') + '...'); // Pipe FFmpeg output to response ffmpegProcess.stdout.pipe(res); @@ -100,8 +100,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod message.includes('Invalid data found when processing input') || message.includes('corrupt decoded frame') || message.includes('decoding for stream')) { - // These indicate problematic audio files that were hopefully filtered out - console.warn(`⚠️ FFmpeg audio warning: ${message.trim()}`); + // Silent - these indicate problematic audio files return; } @@ -119,20 +118,20 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod }); ffmpegProcess.on('error', (error) => { - console.error('FFmpeg error:', error); + console.error(`${streamId} FFmpeg error:`, error); if (typeof cleanup === 'function') cleanup(); }); ffmpegProcess.on('close', (code) => { if (code && code !== 0 && !isCleaningUp) { - console.error(`FFmpeg exited with code ${code}`); + console.error(`${streamId} FFmpeg exited with code ${code}`); if (typeof cleanup === 'function') cleanup(); } }); ffmpegProcess.stdin.on('error', (error) => { if (error.code !== 'EPIPE') { - console.error('FFmpeg stdin error:', error); + console.error(`${streamId} FFmpeg stdin error:`, error); } }); @@ -148,6 +147,8 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod waitForPageFullyLoaded(page, url) .then(async (loaded) => { if (loaded) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + console.log(`${streamId} Stream ready (${elapsed}s)`); sendBlackFrames = false; if (hideLogoFlag === 'true') { await hideLogo(page); @@ -155,7 +156,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod } }) .catch(err => { - console.error('Page load promise error:', err.message); + console.error(`${streamId} Page load error:`, err.message); sendBlackFrames = false; }); } @@ -165,12 +166,12 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod 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'); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + console.log(`${streamId} Stream ready (${elapsed}s)`); waitingForCorrectUrl = false; sendBlackFrames = false; @@ -178,31 +179,33 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod await hideLogo(page); } } catch (err) { - console.error('Location update error:', err.message); + console.error(`${streamId} 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); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + console.log(`${streamId} Stream ready (${elapsed}s)`); waitingForCorrectUrl = false; sendBlackFrames = false; if (hideLogoFlag === 'true') { await hideLogo(page); } } catch (err) { - console.error('Initial page load error:', err.message); + console.error(`${streamId} 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 () => { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + console.log(`${streamId} Stream ready (${elapsed}s)`); waitingForCorrectUrl = false; sendBlackFrames = false; if (hideLogoFlag === 'true') { @@ -221,13 +224,12 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod 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); + // Silent } } }, refreshIntervalMs); @@ -259,7 +261,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod const start = Date.now(); try { if (page.isClosed()) { - console.error('Page was closed unexpectedly'); + console.error(`${streamId} Page closed unexpectedly`); break; } @@ -310,10 +312,10 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod } catch (error) { if (!isCleaningUp) { consecutiveErrors++; - console.error(`Capture error (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, error.message || error); + console.error(`${streamId} Capture error (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, error.message || error); if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { - console.error('Too many consecutive errors, stopping stream'); + console.error(`${streamId} Too many consecutive errors, stopping stream`); try { await cleanup(); } catch (e) {} break; } @@ -336,7 +338,6 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod cleanup = async () => { if (isCleaningUp) return; isCleaningUp = true; - console.log('Cleaning up stream...'); try { captureLoopActive = false; } catch (e) {} try { clearInterval(pageRefreshInterval); } catch (e) {} @@ -356,6 +357,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod ffmpegProcess = null; } + // Clean up per-stream playlist file (rotated copy, not the shared one) if (playlistFile) { try { fs.unlinkSync(playlistFile); @@ -379,7 +381,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod req.on('close', () => { if (!disconnectLogged) { - console.log('Client disconnected'); + console.log(`${streamId} Client disconnected`); disconnectLogged = true; } cleanup(); @@ -388,11 +390,11 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod req.on('error', (error) => { if (error.code === 'ECONNRESET' || error.code === 'EPIPE') { if (!disconnectLogged) { - console.log('Client disconnected'); + console.log(`${streamId} Client disconnected`); disconnectLogged = true; } } else { - console.error('Request error:', error); + console.error(`${streamId} Request error:`, error); } cleanup(); }); @@ -400,11 +402,11 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod res.on('error', (error) => { if (error.code === 'ECONNRESET' || error.code === 'EPIPE') { if (!disconnectLogged) { - console.log('Client disconnected'); + console.log(`${streamId} Client disconnected`); disconnectLogged = true; } } else { - console.error('Response error:', error); + console.error(`${streamId} Response error:`, error); } cleanup(); }); @@ -416,14 +418,14 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod return; } if (res.writableEnded || res.socket?.destroyed) { - console.log('Connection lost, cleaning up'); + console.log(`${streamId} Connection lost`); clearInterval(keepaliveInterval); cleanup(); } }, 10000); } catch (error) { - console.error('Error:', error); + console.error(`${streamId || '[STREAM]'} Error:`, error); if (ffmpegProcess && !ffmpegProcess.killed) ffmpegProcess.kill(); if (browser) await browser.close(); if (!res.headersSent) {