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