diff --git a/Dockerfile b/Dockerfile index 7726e97..75012ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,13 @@ -FROM ghcr.io/mwood77/ws4kp-international:latest +FROM ghcr.io/mwood77/ws4kp-international:sha-9e2080c -# Install FFmpeg, Chromium, wget, unzip, and canvas dependencies +# Install FFmpeg, Chromium, wget, unzip, timezone data, and canvas dependencies RUN apk add --no-cache \ chromium \ ffmpeg \ font-noto-emoji \ wget \ unzip \ + tzdata \ cairo-dev \ jpeg-dev \ pango-dev \ @@ -54,7 +55,8 @@ RUN mkdir -p /music /music-temp && \ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser \ WS4KP_PORT=8080 \ - MUSIC_PATH=/music + MUSIC_PATH=/music \ + TZ=America/New_York # Copy all Star fonts and remove spaces from filenames for FFmpeg RUN mkdir -p /fonts && \ @@ -74,5 +76,5 @@ COPY src/ ./src/ # Expose streaming app port (WS4KP port only exposed when explicitly configured) EXPOSE 3000 -# Start both services using JSON array format +# Start both services - TZ is set via ENV above and will be inherited CMD ["/bin/sh", "-c", "cd /app && node index.js > /dev/null 2>&1 & cd /streaming-app && yarn start"] diff --git a/README.md b/README.md index 994feeb..d4faeec 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Webpage to HLS Stream -Simple Dockerized service that converts any webpage to an HLS stream using Puppeteer and FFmpeg. Includes built-in ws4kp weather display. +Simple Dockerized service that converts any webpage to a live video stream using Puppeteer and FFmpeg. Streams use MPEGTS format for reliable HTTP delivery. Includes built-in ws4kp weather display. **Docker Image**: `ghcr.io/sethwv/ws4kp-to-hls:latest` (multi-platform: AMD64, ARM64) @@ -29,10 +29,18 @@ Configure ports and services via environment variables: PORT=3000 # Main streaming server port WS4KP_EXTERNAL_PORT=8080 # External port for WS4KP interface (optional) MUSIC_PATH=/music # Path to music files +LOG_WS4KP_URL=false # When true, logs full ws4kp URL instead of just request path +TZ=America/New_York # Timezone for weather display (default: America/New_York) # Example with custom ports PORT=8000 WS4KP_EXTERNAL_PORT=9090 docker-compose up +# Run with full ws4kp URL logging +LOG_WS4KP_URL=true docker-compose up + +# Run with different timezone (e.g., Los Angeles) +TZ=America/Los_Angeles docker-compose up + # Run without exposing WS4KP externally (only accessible internally to the streaming app) # Comment out the WS4KP port line in docker-compose.yml ``` @@ -41,8 +49,12 @@ Or use a `.env` file with docker-compose: ```env PORT=8000 WS4KP_EXTERNAL_PORT=9090 +LOG_WS4KP_URL=true +TZ=America/Chicago ``` +**Timezone Note**: The `TZ` environment variable controls what time is displayed in the weather stream. Use standard IANA timezone identifiers (e.g., `America/New_York`, `America/Chicago`, `America/Los_Angeles`, `Europe/London`, `Asia/Tokyo`). This should match the timezone of the location you're streaming weather for. + **Note**: The WS4KP weather service runs internally on port 8080 inside the container and is always accessible to the streaming app. The `WS4KP_EXTERNAL_PORT` controls whether it's exposed externally. If you don't need direct access to the WS4KP interface, you can comment out the WS4KP port mapping in `docker-compose.yml` to keep it internal-only. ### Persistent Geocoding Cache @@ -180,9 +192,9 @@ ffmpeg -i "http://localhost:3000/stream?url=http://example.com" \ 1. Puppeteer opens the webpage in headless Chrome/Chromium 2. Screenshots are captured at the specified FPS using a sequential loop with backpressure handling -3. FFmpeg encodes the screenshots into an HLS stream (H.264 video, AAC audio for weather) +3. FFmpeg encodes the screenshots into an MPEGTS stream (H.264 video, AAC audio for weather) 4. For weather streams: background music is shuffled and played from the Weatherscan collection -5. The HLS stream is piped directly to the HTTP response +5. The MPEGTS stream is piped directly to the HTTP response 6. City names are automatically geocoded to coordinates via OpenStreetMap's Nominatim API (results are cached locally for performance) ## Features diff --git a/build-commands.txt b/build-commands.txt index b2759ba..daa4cd0 100644 --- a/build-commands.txt +++ b/build-commands.txt @@ -13,6 +13,8 @@ docker buildx use multiplatform docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/sethwv/ws4kp-to-hls:latest --push . +docker buildx build --no-cache --pull --platform linux/amd64,linux/arm64 -t ghcr.io/sethwv/ws4kp-to-hls:latest --push . + ## Build without pushing (for testing) docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/sethwv/ws4kp-to-hls:latest . diff --git a/docker-compose.yml b/docker-compose.yml index e714b70..ce584d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - SCREENSHOT_FORMAT=${SCREENSHOT_FORMAT:-jpeg} - SCREENSHOT_QUALITY=${SCREENSHOT_QUALITY:-95} - DEBUG_MODE=${DEBUG_MODE:-false} + - LOG_WS4KP_URL=${LOG_WS4KP_URL:-false} + - TZ=${TZ:-America/New_York} volumes: - ./cache:/streaming-app/cache restart: unless-stopped diff --git a/index.js b/index.js index 9aaf5a9..1a435b3 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ const DEFAULT_FPS = parseInt(process.env.DEFAULT_FPS || '30'); const SCREENSHOT_FORMAT = process.env.SCREENSHOT_FORMAT || 'jpeg'; const SCREENSHOT_QUALITY = parseInt(process.env.SCREENSHOT_QUALITY || '95'); const DEBUG_MODE = process.env.DEBUG_MODE === 'true'; +const LOG_WS4KP_URL = process.env.LOG_WS4KP_URL === 'true'; /** * Build WS4KP weather URL with given coordinates and settings @@ -167,8 +168,12 @@ app.get('/weather', async (req, res) => { } else { // Toronto default initialUrl = buildWeatherUrl(43.6532, -79.3832, weatherSettings); - // Create resolved promise with Toronto data - geocodeDataPromise = Promise.resolve({ cityName: 'Toronto' }); + // Create resolved promise with Toronto data including coordinates + geocodeDataPromise = Promise.resolve({ + cityName: 'Toronto', + lat: 43.6532, + lon: -79.3832 + }); } const startTime = Date.now(); @@ -183,8 +188,14 @@ 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)}`; + // Build request path for logging - optionally include full ws4kp URL + let requestPath = `/weather?city=${encodeURIComponent(city)}`; + if (LOG_WS4KP_URL && initialUrl.startsWith('http')) { + requestPath = initialUrl; + } else if (LOG_WS4KP_URL && lateGeocodePromise) { + // For late geocoded URLs, we'll need to pass a promise that resolves to the URL + requestPath = lateGeocodePromise; + } return streamHandler(req, res, { useMusic: true, diff --git a/package.json b/package.json index f95a5b2..fceffd4 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "canvas": "^2.11.2", "express": "^4.18.2", + "geo-tz": "^8.1.1", "puppeteer": "^24.15.0" } } diff --git a/src/ffmpegConfig.js b/src/ffmpegConfig.js index 9f84d97..eb1dfef 100644 --- a/src/ffmpegConfig.js +++ b/src/ffmpegConfig.js @@ -109,15 +109,9 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath, inputFormat = 'jpeg', '-copytb', '0', // Don't copy input timebase '-max_interleave_delta', '500000', // Increased for smoother transitions (500ms) '-err_detect', 'ignore_err', // Continue on minor audio errors - '-f', 'hls', - '-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', - '-hls_start_number_source', 'epoch', - '-start_number', '0', // Start from segment 0 + '-f', 'mpegts', // Use MPEGTS for direct streaming to pipe + '-mpegts_flags', 'resend_headers', // Resend headers for stream recovery '-flush_packets', '1', // Flush packets immediately - '-hls_allow_cache', '0', // Disable client caching 'pipe:1' ); @@ -148,15 +142,9 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath, inputFormat = 'jpeg', '-b:v', '2500k', '-maxrate', '2500k', '-bufsize', '5000k', - '-f', 'hls', - '-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', - '-hls_start_number_source', 'epoch', - '-start_number', '0', + '-f', 'mpegts', // Use MPEGTS for direct streaming to pipe + '-mpegts_flags', 'resend_headers', // Resend headers for stream recovery '-flush_packets', '1', // Flush packets immediately - '-hls_allow_cache', '0', // Disable client caching 'pipe:1' ); diff --git a/src/pageLoader.js b/src/pageLoader.js index b3e1044..292f00b 100644 --- a/src/pageLoader.js +++ b/src/pageLoader.js @@ -4,10 +4,150 @@ * @param {Object} options - Configuration options * @param {number} options.width - Page width * @param {number} options.height - Page height + * @param {string} options.sessionId - Session ID for logging (optional) + * @param {string} options.timezone - Timezone identifier (e.g., 'America/New_York', 'Europe/Rome') * @returns {Promise} Configured Puppeteer page */ -async function setupPage(browser, { width, height }) { +async function setupPage(browser, { width, height, sessionId = null, timezone = null }) { const page = await browser.newPage(); + const prefix = sessionId ? `[${sessionId}] ` : ''; + + // Use provided timezone or fall back to environment variable + const tz = timezone || process.env.TZ || 'UTC'; + + // Enable request interception to patch ws4kp's JavaScript + await page.setRequestInterception(true); + + page.on('request', (request) => { + // Let all requests through unchanged + request.continue(); + }); + + + + try { + await page.emulateTimezone(tz); + + // Inject comprehensive timezone override AND clock hijacking before any page scripts run + await page.evaluateOnNewDocument((tz) => { + // Calculate offset for timezone (these are standard time, DST would need more logic) + const timezoneOffsets = { + 'America/New_York': -300, // EST: UTC-5 (minutes) + 'America/Chicago': -360, // CST: UTC-6 + 'America/Denver': -420, // MST: UTC-7 + 'America/Los_Angeles': -480, // PST: UTC-8 + 'America/Phoenix': -420, // MST (no DST) + 'America/Toronto': -300, // EST + 'Europe/London': 0, // GMT + 'UTC': 0 + }; + + const offsetMinutes = timezoneOffsets[tz] || 0; + + // Override getTimezoneOffset + const originalGetTimezoneOffset = Date.prototype.getTimezoneOffset; + Date.prototype.getTimezoneOffset = function() { + return -offsetMinutes; // Note: getTimezoneOffset returns negative values for positive offsets + }; + + // Override Intl.DateTimeFormat to force timezone + const OriginalDateTimeFormat = Intl.DateTimeFormat; + Intl.DateTimeFormat = function(locales, options) { + options = options || {}; + if (!options.timeZone) { + options.timeZone = tz; + } + return new OriginalDateTimeFormat(locales, options); + }; + Object.setPrototypeOf(Intl.DateTimeFormat, OriginalDateTimeFormat); + Intl.DateTimeFormat.supportedLocalesOf = OriginalDateTimeFormat.supportedLocalesOf; + + // Function to get corrected local time + function getLocalTime() { + const now = new Date(); + return now.toLocaleTimeString('en-US', { + timeZone: tz, + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + + // Hijack all text-setting properties to intercept clock updates + const timeRegex = /^\d{2}:\d{2}:\d{2}$/; + + // Intercept textContent + const textContentDescriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent'); + Object.defineProperty(Node.prototype, 'textContent', { + set: function(value) { + if (typeof value === 'string' && timeRegex.test(value.trim())) { + textContentDescriptor.set.call(this, getLocalTime()); + return; + } + textContentDescriptor.set.call(this, value); + }, + get: textContentDescriptor.get, + configurable: true + }); + + // Intercept innerText + const innerTextDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText'); + Object.defineProperty(HTMLElement.prototype, 'innerText', { + set: function(value) { + if (typeof value === 'string' && timeRegex.test(value.trim())) { + innerTextDescriptor.set.call(this, getLocalTime()); + return; + } + innerTextDescriptor.set.call(this, value); + }, + get: innerTextDescriptor.get, + configurable: true + }); + + // Intercept innerHTML (in case they wrap time in HTML) + const innerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML'); + Object.defineProperty(Element.prototype, 'innerHTML', { + set: function(value) { + if (typeof value === 'string' && timeRegex.test(value.trim())) { + innerHTMLDescriptor.set.call(this, getLocalTime()); + return; + } + innerHTMLDescriptor.set.call(this, value); + }, + get: innerHTMLDescriptor.get, + configurable: true + }); + + + }, tz); + + // Verify the timezone + const actualTz = await page.evaluate(() => { + const date = new Date(); + const offset = -date.getTimezoneOffset() / 60; + const formatter = new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: 'numeric', + hour12: true + }); + return { + offset, + time: formatter.format(date) + }; + }); + + console.log(`${prefix}Timezone: ${tz} (UTC${actualTz.offset >= 0 ? '+' : ''}${actualTz.offset})`); + } catch (err) { + console.error(`${prefix}Failed to set timezone to ${tz}:`, err.message); + // Try as fallback with browser context + try { + const context = browser.defaultBrowserContext(); + await context.overridePermissions('http://localhost:8080', []); + } catch (e) { + // Ignore + } + } // Reduce memory usage by disabling caching await page.setCacheEnabled(false); diff --git a/src/streamHandler.js b/src/streamHandler.js index 6f801b4..ac7b97d 100644 --- a/src/streamHandler.js +++ b/src/streamHandler.js @@ -1,6 +1,7 @@ const puppeteer = require('puppeteer'); const { spawn } = require('child_process'); const fs = require('fs'); +const geoTz = require('geo-tz'); const { buildFFmpegArgs } = require('./ffmpegConfig'); const { setupPage, waitForPageFullyLoaded, hideLogo, startAutoScroll } = require('./pageLoader'); @@ -41,8 +42,16 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod let streamId = `[${sessionId}]`; // Log the actual request path if provided, otherwise the URL - const logUrl = requestPath || url; - console.log(`${streamId} New stream request: ${logUrl}`); + // requestPath might be a Promise (for late-geocoded ws4kp URLs) + if (requestPath instanceof Promise) { + console.log(`${streamId} New stream request: /weather (ws4kp URL pending geocode...)`); + requestPath.then(resolvedUrl => { + console.log(`${streamId} ws4kp URL: ${resolvedUrl}`); + }).catch(() => {}); // Silently ignore errors + } else { + const logUrl = requestPath || url; + console.log(`${streamId} New stream request: ${logUrl}`); + } let browser = null; let ffmpegProcess = null; @@ -52,8 +61,8 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod let stopAutoScroll = null; // Function to stop auto-scrolling try { - // Set HLS headers - res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); + // Set MPEGTS streaming headers + res.setHeader('Content-Type', 'video/mp2t'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); @@ -87,6 +96,10 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod const browserPromise = puppeteer.launch({ headless: true, executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, + env: { + ...process.env, + TZ: process.env.TZ || 'UTC' // Pass timezone to Chromium + }, args: [ '--no-sandbox', '--disable-setuid-sandbox', @@ -134,11 +147,11 @@ 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)`); + // Log stream ready time + const streamElapsed = ((Date.now() - startTime) / 1000).toFixed(2); + console.log(`${streamId} MPEGTS stream ready (${streamElapsed}s)`); - // Pre-fill buffer with black frames to create initial segments instantly + // Pre-fill buffer with black frames to start streaming immediately // This allows clients to start playback immediately (10 seconds worth) const preFillSeconds = 10; const preFillFrames = fps * preFillSeconds; @@ -231,14 +244,35 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod } }); - // Setup Puppeteer page - const page = await setupPage(browser, { width: parseInt(width), height: parseInt(height) }); + // Determine timezone for the location + let timezone = null; + if (geocodeDataPromise) { + try { + const geocodeData = await geocodeDataPromise; + if (geocodeData?.lat && geocodeData?.lon) { + const timezones = geoTz.find(geocodeData.lat, geocodeData.lon); + if (timezones && timezones.length > 0) { + timezone = timezones[0]; + console.log(`${streamId} Detected timezone: ${timezone} for ${geocodeData.cityName || 'location'} (${geocodeData.lat}, ${geocodeData.lon})`); + } + } + } catch (err) { + console.warn(`${streamId} Failed to determine timezone:`, err.message); + } + } - // Capture browser console logs for debugging scroll + // Setup Puppeteer page with timezone + const page = await setupPage(browser, { + width: parseInt(width), + height: parseInt(height), + sessionId, + timezone // Pass location-specific timezone + }); + + // Capture browser console errors only page.on('console', msg => { - const text = msg.text(); - if (text.includes('[Scroll]')) { - console.log(`${streamId} ${text}`); + if (msg.type() === 'error') { + console.error(`${streamId} Browser error:`, msg.text()); } });