1
0

Add timezone detection and clock correction for weather streams

This commit is contained in:
2026-02-20 17:11:34 -05:00
parent be7a047318
commit c35b14c6e0
9 changed files with 234 additions and 42 deletions

View File

@@ -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 \ RUN apk add --no-cache \
chromium \ chromium \
ffmpeg \ ffmpeg \
font-noto-emoji \ font-noto-emoji \
wget \ wget \
unzip \ unzip \
tzdata \
cairo-dev \ cairo-dev \
jpeg-dev \ jpeg-dev \
pango-dev \ pango-dev \
@@ -54,7 +55,8 @@ RUN mkdir -p /music /music-temp && \
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser \ PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser \
WS4KP_PORT=8080 \ WS4KP_PORT=8080 \
MUSIC_PATH=/music MUSIC_PATH=/music \
TZ=America/New_York
# Copy all Star fonts and remove spaces from filenames for FFmpeg # Copy all Star fonts and remove spaces from filenames for FFmpeg
RUN mkdir -p /fonts && \ RUN mkdir -p /fonts && \
@@ -74,5 +76,5 @@ COPY src/ ./src/
# Expose streaming app port (WS4KP port only exposed when explicitly configured) # Expose streaming app port (WS4KP port only exposed when explicitly configured)
EXPOSE 3000 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"] CMD ["/bin/sh", "-c", "cd /app && node index.js > /dev/null 2>&1 & cd /streaming-app && yarn start"]

View File

@@ -1,6 +1,6 @@
# Webpage to HLS Stream # 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) **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 PORT=3000 # Main streaming server port
WS4KP_EXTERNAL_PORT=8080 # External port for WS4KP interface (optional) WS4KP_EXTERNAL_PORT=8080 # External port for WS4KP interface (optional)
MUSIC_PATH=/music # Path to music files 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 # Example with custom ports
PORT=8000 WS4KP_EXTERNAL_PORT=9090 docker-compose up 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) # Run without exposing WS4KP externally (only accessible internally to the streaming app)
# Comment out the WS4KP port line in docker-compose.yml # Comment out the WS4KP port line in docker-compose.yml
``` ```
@@ -41,8 +49,12 @@ Or use a `.env` file with docker-compose:
```env ```env
PORT=8000 PORT=8000
WS4KP_EXTERNAL_PORT=9090 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. **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 ### 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 1. Puppeteer opens the webpage in headless Chrome/Chromium
2. Screenshots are captured at the specified FPS using a sequential loop with backpressure handling 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 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) 6. City names are automatically geocoded to coordinates via OpenStreetMap's Nominatim API (results are cached locally for performance)
## Features ## Features

View File

@@ -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 --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) ## Build without pushing (for testing)
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/sethwv/ws4kp-to-hls:latest . docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/sethwv/ws4kp-to-hls:latest .

View File

@@ -13,6 +13,8 @@ services:
- SCREENSHOT_FORMAT=${SCREENSHOT_FORMAT:-jpeg} - SCREENSHOT_FORMAT=${SCREENSHOT_FORMAT:-jpeg}
- SCREENSHOT_QUALITY=${SCREENSHOT_QUALITY:-95} - SCREENSHOT_QUALITY=${SCREENSHOT_QUALITY:-95}
- DEBUG_MODE=${DEBUG_MODE:-false} - DEBUG_MODE=${DEBUG_MODE:-false}
- LOG_WS4KP_URL=${LOG_WS4KP_URL:-false}
- TZ=${TZ:-America/New_York}
volumes: volumes:
- ./cache:/streaming-app/cache - ./cache:/streaming-app/cache
restart: unless-stopped restart: unless-stopped

View File

@@ -11,6 +11,7 @@ const DEFAULT_FPS = parseInt(process.env.DEFAULT_FPS || '30');
const SCREENSHOT_FORMAT = process.env.SCREENSHOT_FORMAT || 'jpeg'; const SCREENSHOT_FORMAT = process.env.SCREENSHOT_FORMAT || 'jpeg';
const SCREENSHOT_QUALITY = parseInt(process.env.SCREENSHOT_QUALITY || '95'); const SCREENSHOT_QUALITY = parseInt(process.env.SCREENSHOT_QUALITY || '95');
const DEBUG_MODE = process.env.DEBUG_MODE === 'true'; 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 * Build WS4KP weather URL with given coordinates and settings
@@ -167,8 +168,12 @@ app.get('/weather', async (req, res) => {
} else { } else {
// Toronto default // Toronto default
initialUrl = buildWeatherUrl(43.6532, -79.3832, weatherSettings); initialUrl = buildWeatherUrl(43.6532, -79.3832, weatherSettings);
// Create resolved promise with Toronto data // Create resolved promise with Toronto data including coordinates
geocodeDataPromise = Promise.resolve({ cityName: 'Toronto' }); geocodeDataPromise = Promise.resolve({
cityName: 'Toronto',
lat: 43.6532,
lon: -79.3832
});
} }
const startTime = Date.now(); const startTime = Date.now();
@@ -183,8 +188,14 @@ app.get('/weather', async (req, res) => {
// Call stream handler with music enabled // Call stream handler with music enabled
const { debug = DEBUG_MODE ? 'true' : 'false' } = req.query; const { debug = DEBUG_MODE ? 'true' : 'false' } = req.query;
// Build request path for logging // Build request path for logging - optionally include full ws4kp URL
const requestPath = `/weather?city=${encodeURIComponent(city)}`; 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, { return streamHandler(req, res, {
useMusic: true, useMusic: true,

View File

@@ -17,6 +17,7 @@
"dependencies": { "dependencies": {
"canvas": "^2.11.2", "canvas": "^2.11.2",
"express": "^4.18.2", "express": "^4.18.2",
"geo-tz": "^8.1.1",
"puppeteer": "^24.15.0" "puppeteer": "^24.15.0"
} }
} }

View File

@@ -109,15 +109,9 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath, inputFormat = 'jpeg',
'-copytb', '0', // Don't copy input timebase '-copytb', '0', // Don't copy input timebase
'-max_interleave_delta', '500000', // Increased for smoother transitions (500ms) '-max_interleave_delta', '500000', // Increased for smoother transitions (500ms)
'-err_detect', 'ignore_err', // Continue on minor audio errors '-err_detect', 'ignore_err', // Continue on minor audio errors
'-f', 'hls', '-f', 'mpegts', // Use MPEGTS for direct streaming to pipe
'-hls_time', '8', // 8-second segments (standard HLS) '-mpegts_flags', 'resend_headers', // Resend headers for stream recovery
'-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
'-flush_packets', '1', // Flush packets immediately '-flush_packets', '1', // Flush packets immediately
'-hls_allow_cache', '0', // Disable client caching
'pipe:1' 'pipe:1'
); );
@@ -148,15 +142,9 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath, inputFormat = 'jpeg',
'-b:v', '2500k', '-b:v', '2500k',
'-maxrate', '2500k', '-maxrate', '2500k',
'-bufsize', '5000k', '-bufsize', '5000k',
'-f', 'hls', '-f', 'mpegts', // Use MPEGTS for direct streaming to pipe
'-hls_time', '8', // 8-second segments (standard HLS) '-mpegts_flags', 'resend_headers', // Resend headers for stream recovery
'-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',
'-flush_packets', '1', // Flush packets immediately '-flush_packets', '1', // Flush packets immediately
'-hls_allow_cache', '0', // Disable client caching
'pipe:1' 'pipe:1'
); );

View File

@@ -4,10 +4,150 @@
* @param {Object} options - Configuration options * @param {Object} options - Configuration options
* @param {number} options.width - Page width * @param {number} options.width - Page width
* @param {number} options.height - Page height * @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<Page>} Configured Puppeteer page * @returns {Promise<Page>} 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 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 // Reduce memory usage by disabling caching
await page.setCacheEnabled(false); await page.setCacheEnabled(false);

View File

@@ -1,6 +1,7 @@
const puppeteer = require('puppeteer'); const puppeteer = require('puppeteer');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const fs = require('fs'); const fs = require('fs');
const geoTz = require('geo-tz');
const { buildFFmpegArgs } = require('./ffmpegConfig'); const { buildFFmpegArgs } = require('./ffmpegConfig');
const { setupPage, waitForPageFullyLoaded, hideLogo, startAutoScroll } = require('./pageLoader'); const { setupPage, waitForPageFullyLoaded, hideLogo, startAutoScroll } = require('./pageLoader');
@@ -41,8 +42,16 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
let streamId = `[${sessionId}]`; let streamId = `[${sessionId}]`;
// Log the actual request path if provided, otherwise the URL // Log the actual request path if provided, otherwise the URL
// 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; const logUrl = requestPath || url;
console.log(`${streamId} New stream request: ${logUrl}`); console.log(`${streamId} New stream request: ${logUrl}`);
}
let browser = null; let browser = null;
let ffmpegProcess = 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 let stopAutoScroll = null; // Function to stop auto-scrolling
try { try {
// Set HLS headers // Set MPEGTS streaming headers
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); res.setHeader('Content-Type', 'video/mp2t');
res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive'); res.setHeader('Connection', 'keep-alive');
@@ -87,6 +96,10 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
const browserPromise = puppeteer.launch({ const browserPromise = puppeteer.launch({
headless: true, headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
env: {
...process.env,
TZ: process.env.TZ || 'UTC' // Pass timezone to Chromium
},
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--disable-setuid-sandbox', '--disable-setuid-sandbox',
@@ -134,11 +147,11 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
// Pipe FFmpeg output to response // Pipe FFmpeg output to response
ffmpegProcess.stdout.pipe(res); ffmpegProcess.stdout.pipe(res);
// Log HLS stream ready time // Log stream ready time
const hlsElapsed = ((Date.now() - startTime) / 1000).toFixed(2); const streamElapsed = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`${streamId} HLS stream ready (${hlsElapsed}s)`); 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) // This allows clients to start playback immediately (10 seconds worth)
const preFillSeconds = 10; const preFillSeconds = 10;
const preFillFrames = fps * preFillSeconds; const preFillFrames = fps * preFillSeconds;
@@ -231,14 +244,35 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
} }
}); });
// Setup Puppeteer page // Determine timezone for the location
const page = await setupPage(browser, { width: parseInt(width), height: parseInt(height) }); 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 => { page.on('console', msg => {
const text = msg.text(); if (msg.type() === 'error') {
if (text.includes('[Scroll]')) { console.error(`${streamId} Browser error:`, msg.text());
console.log(`${streamId} ${text}`);
} }
}); });