Add timezone detection and clock correction for weather streams
This commit is contained in:
10
Dockerfile
10
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 \
|
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"]
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -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
|
||||||
|
|||||||
@@ -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 .
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
19
index.js
19
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_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,
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
const logUrl = requestPath || url;
|
// requestPath might be a Promise (for late-geocoded ws4kp URLs)
|
||||||
console.log(`${streamId} New stream request: ${logUrl}`);
|
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 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}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user