prismcast 1.5.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -28
- package/dist/app.d.ts +5 -0
- package/dist/app.js +63 -5
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.js +1 -1
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +10 -34
- package/dist/browser/channelSelection.js +31 -96
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/display.d.ts +19 -0
- package/dist/browser/display.js +16 -11
- package/dist/browser/display.js.map +1 -1
- package/dist/browser/index.d.ts +31 -54
- package/dist/browser/index.js +334 -272
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.d.ts +65 -0
- package/dist/browser/login.js +193 -0
- package/dist/browser/login.js.map +1 -0
- package/dist/browser/precaching.js +15 -3
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
- package/dist/browser/tuning/comcastPolymer.js +655 -0
- package/dist/browser/tuning/comcastPolymer.js.map +1 -0
- package/dist/browser/tuning/cox.d.ts +2 -0
- package/dist/browser/tuning/cox.js +14 -0
- package/dist/browser/tuning/cox.js.map +1 -0
- package/dist/browser/tuning/directv.js +14 -1
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +14 -2
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.js +12 -1
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.js +24 -1
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/shared.d.ts +46 -0
- package/dist/browser/tuning/shared.js +100 -0
- package/dist/browser/tuning/shared.js.map +1 -0
- package/dist/browser/tuning/sling.js +35 -5
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/browser/tuning/spectrum.js +13 -1
- package/dist/browser/tuning/spectrum.js.map +1 -1
- package/dist/browser/tuning/thumbnailRow.js +1 -1
- package/dist/browser/tuning/thumbnailRow.js.map +1 -1
- package/dist/browser/tuning/tileClick.js +1 -1
- package/dist/browser/tuning/tileClick.js.map +1 -1
- package/dist/browser/tuning/xfinity.d.ts +2 -0
- package/dist/browser/tuning/xfinity.js +14 -0
- package/dist/browser/tuning/xfinity.js.map +1 -0
- package/dist/browser/tuning/youtubeTv.js +15 -1
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +20 -0
- package/dist/browser/video.js +168 -124
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.d.ts +2 -2
- package/dist/channels/index.js +2075 -683
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +28 -28
- package/dist/config/health.js +72 -54
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +29 -64
- package/dist/config/index.js.map +1 -1
- package/dist/config/paths.d.ts +12 -0
- package/dist/config/paths.js +16 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/profiles.d.ts +3 -2
- package/dist/config/profiles.js +45 -34
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providerPacks.js +4 -1
- package/dist/config/providerPacks.js.map +1 -1
- package/dist/config/providers.d.ts +19 -5
- package/dist/config/providers.js +123 -93
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.d.ts +28 -0
- package/dist/config/sites.js +104 -127
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +53 -2
- package/dist/config/userChannels.js +222 -13
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +2 -1
- package/dist/config/userConfig.js +6 -15
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +0 -1
- package/dist/config/userProfiles.js +18 -10
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +4 -4
- package/dist/hdhr/channelMap.js.map +1 -1
- package/dist/index.js +25 -22
- package/dist/index.js.map +1 -1
- package/dist/native/decrypt.d.ts +32 -0
- package/dist/native/decrypt.js +85 -0
- package/dist/native/decrypt.js.map +1 -0
- package/dist/native/index.d.ts +61 -0
- package/dist/native/index.js +342 -0
- package/dist/native/index.js.map +1 -0
- package/dist/native/intercept.d.ts +35 -0
- package/dist/native/intercept.js +184 -0
- package/dist/native/intercept.js.map +1 -0
- package/dist/native/probe.d.ts +49 -0
- package/dist/native/probe.js +294 -0
- package/dist/native/probe.js.map +1 -0
- package/dist/native/proxy.d.ts +54 -0
- package/dist/native/proxy.js +947 -0
- package/dist/native/proxy.js.map +1 -0
- package/dist/routes/components.d.ts +43 -0
- package/dist/routes/components.js +122 -0
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +2 -2
- package/dist/routes/config/channels/index.js +1 -1
- package/dist/routes/config/channels/index.js.map +1 -1
- package/dist/routes/config/channels/routes.js +443 -65
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +76 -0
- package/dist/routes/config/channels/table.js +369 -89
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/index.d.ts +1 -1
- package/dist/routes/config/index.js +1 -1
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/providers.d.ts +6 -6
- package/dist/routes/config/providers.js +73 -99
- package/dist/routes/config/providers.js.map +1 -1
- package/dist/routes/debug.js +5 -5
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/hls.js +12 -2
- package/dist/routes/hls.js.map +1 -1
- package/dist/routes/index.d.ts +0 -15
- package/dist/routes/index.js +2 -16
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/providers.js +106 -3
- package/dist/routes/providers.js.map +1 -1
- package/dist/routes/root/content.js +124 -47
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/index.js +2 -1
- package/dist/routes/root/index.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +772 -312
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +596 -544
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/index.d.ts +1 -0
- package/dist/routes/root/scripts/index.js +1 -0
- package/dist/routes/root/scripts/index.js.map +1 -1
- package/dist/routes/root/scripts/shared.d.ts +6 -0
- package/dist/routes/root/scripts/shared.js +391 -0
- package/dist/routes/root/scripts/shared.js.map +1 -0
- package/dist/routes/root/scripts/status.js +49 -33
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +117 -10
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/service/commands.js +2 -1
- package/dist/service/commands.js.map +1 -1
- package/dist/streaming/fmp4Segmenter.d.ts +5 -0
- package/dist/streaming/fmp4Segmenter.js +146 -42
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +24 -19
- package/dist/streaming/hls.js +603 -297
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.d.ts +16 -3
- package/dist/streaming/hlsResume.js +35 -8
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/hlsSegments.d.ts +41 -2
- package/dist/streaming/hlsSegments.js +131 -28
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.js +27 -13
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.d.ts +1 -0
- package/dist/streaming/monitor.js +862 -474
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mp4Parser.d.ts +52 -6
- package/dist/streaming/mp4Parser.js +156 -30
- package/dist/streaming/mp4Parser.js.map +1 -1
- package/dist/streaming/mpegts.d.ts +2 -2
- package/dist/streaming/mpegts.js +156 -109
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/playlistBuilder.d.ts +38 -0
- package/dist/streaming/playlistBuilder.js +66 -0
- package/dist/streaming/playlistBuilder.js.map +1 -0
- package/dist/streaming/preroll.d.ts +126 -0
- package/dist/streaming/preroll.js +434 -0
- package/dist/streaming/preroll.js.map +1 -0
- package/dist/streaming/pretune.d.ts +8 -0
- package/dist/streaming/pretune.js +226 -0
- package/dist/streaming/pretune.js.map +1 -0
- package/dist/streaming/recovery.d.ts +0 -6
- package/dist/streaming/recovery.js +0 -8
- package/dist/streaming/recovery.js.map +1 -1
- package/dist/streaming/registry.d.ts +57 -9
- package/dist/streaming/registry.js +26 -7
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +8 -1
- package/dist/streaming/setup.js +71 -27
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.d.ts +29 -4
- package/dist/streaming/showInfo.js +210 -38
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +20 -6
- package/dist/streaming/statusEmitter.js +31 -7
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +32 -0
- package/dist/types/config.d.ts +2 -2
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +13 -1
- package/dist/types/selection.d.ts +5 -1
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +7 -0
- package/dist/utils/chromeFetch.d.ts +20 -0
- package/dist/utils/chromeFetch.js +43 -0
- package/dist/utils/chromeFetch.js.map +1 -0
- package/dist/utils/debugFilter.d.ts +0 -5
- package/dist/utils/debugFilter.js +20 -11
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/delay.d.ts +28 -0
- package/dist/utils/delay.js +35 -1
- package/dist/utils/delay.js.map +1 -1
- package/dist/utils/evaluate.js +6 -11
- package/dist/utils/evaluate.js.map +1 -1
- package/dist/utils/ffmpeg.d.ts +14 -12
- package/dist/utils/ffmpeg.js +114 -134
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +8 -0
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logEmitter.js +1 -1
- package/dist/utils/logEmitter.js.map +1 -1
- package/dist/utils/pid.d.ts +40 -0
- package/dist/utils/pid.js +83 -0
- package/dist/utils/pid.js.map +1 -0
- package/dist/utils/retry.d.ts +15 -9
- package/dist/utils/retry.js +7 -23
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/timing.d.ts +5 -0
- package/dist/utils/timing.js +13 -0
- package/dist/utils/timing.js.map +1 -0
- package/dist/utils/version.js +2 -2
- package/dist/utils/version.js.map +1 -1
- package/package.json +7 -7
package/dist/browser/index.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getGpuCapabilities, setBrowserChrome, setGpuCapabilities, setMaxSupportedViewport } from "./display.js";
|
|
2
|
+
import { LOG, cancellableTimeout, clearPidFile, evaluateWithAbort, formatError, isProcessRunning, readPidFile, startTimer, writePidFile } from "../utils/index.js";
|
|
3
|
+
import { clearLoginState, isLoginModeActive, setBrowserAccessors } from "./login.js";
|
|
2
4
|
import { getAllStreams, getStreamCount } from "../streaming/registry.js";
|
|
3
|
-
import { getChromeDataDir, getDataDir, getExtensionDir } from "../config/paths.js";
|
|
5
|
+
import { getChromeDataDir, getChromePidFilePath, getDataDir, getExtensionDir } from "../config/paths.js";
|
|
4
6
|
import { getEffectivePreset, getPresetViewport } from "../config/presets.js";
|
|
5
7
|
import { getExtensionPage, getStream, launch } from "puppeteer-stream";
|
|
6
8
|
import { resizeAndMinimizeWindow, unminimizeWindow } from "./cdp.js";
|
|
7
|
-
import { setBrowserChrome, setMaxSupportedViewport } from "./display.js";
|
|
8
9
|
import { CONFIG } from "../config/index.js";
|
|
9
10
|
import { clearChannelSelectionCaches } from "./channelSelection.js";
|
|
10
11
|
import { emitSystemStatusChanged } from "../streaming/statusEmitter.js";
|
|
11
|
-
import { execSync } from "node:child_process";
|
|
12
12
|
import fs from "node:fs";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { launch as puppeteerLaunch } from "puppeteer-core";
|
|
15
|
+
import { setChromeUserAgent } from "../utils/index.js";
|
|
15
16
|
import { startPrecaching } from "./precaching.js";
|
|
16
17
|
import { terminateStream } from "../streaming/lifecycle.js";
|
|
17
18
|
const { promises: fsPromises } = fs;
|
|
@@ -24,11 +25,18 @@ const { promises: fsPromises } = fs;
|
|
|
24
25
|
* - dataDir: The filesystem location for persistent data (Chrome profile, extension files). Resolved via config/paths.ts, which is created on startup if it doesn't
|
|
25
26
|
* exist.
|
|
26
27
|
*
|
|
27
|
-
* Stream tracking and ID generation have been moved to streaming/registry.ts for unified stream management across all output types (
|
|
28
|
+
* Stream tracking and ID generation have been moved to streaming/registry.ts for unified stream management across all output types (HLS, MPEG-TS, etc.).
|
|
28
29
|
*/
|
|
29
30
|
// The shared browser instance used by all streaming sessions. Created on first stream request or during warmup. Set to null when the browser is not running or
|
|
30
31
|
// has disconnected.
|
|
31
32
|
let currentBrowser = null;
|
|
33
|
+
// The PID of the Chrome process launched by Puppeteer. Tracked in memory for fast access and persisted to a PID file on disk so that orphaned Chrome processes
|
|
34
|
+
// can be cleaned up after a crash or container restart without relying on Unix-only tools like pkill/pgrep.
|
|
35
|
+
let chromePid = null;
|
|
36
|
+
// Tracks whether this process has taken ownership of Chrome cleanup by running killStaleChrome() during startup. The exit handler checks this flag to avoid
|
|
37
|
+
// killing Chrome that belongs to another running PrismCast instance — e.g., when a duplicate instance is rejected by the instance guard and exits before
|
|
38
|
+
// killStaleChrome() runs in the startup sequence.
|
|
39
|
+
let ownsChromeCleanup = false;
|
|
32
40
|
// The Chrome version string (e.g., "Chrome/144.0.7559.110") captured when the browser launches. Cleared when the browser disconnects. Used by the
|
|
33
41
|
// health endpoint to report the active Chrome version.
|
|
34
42
|
let currentChromeVersion = null;
|
|
@@ -89,27 +97,13 @@ const managedPageIds = new Set();
|
|
|
89
97
|
// Map from page ID to timestamp when a page was first observed as potentially stale (not associated with an active stream). Pages must remain in this state for
|
|
90
98
|
// the configured grace period before being closed. This prevents race conditions where pages are briefly untracked during initialization or cleanup transitions.
|
|
91
99
|
const potentiallyStalePages = new Map();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
* The login page is NOT registered as a managed page to exclude it from stale page cleanup. We manage its lifecycle explicitly through startLoginMode/endLoginMode.
|
|
100
|
-
*/
|
|
101
|
-
// Whether login mode is currently active.
|
|
102
|
-
let loginModeActive = false;
|
|
103
|
-
// The browser page (tab) used for login. Set when login starts, cleared when login ends.
|
|
104
|
-
let loginPage = null;
|
|
105
|
-
// The URL being used for login. Stored for status reporting.
|
|
106
|
-
let loginUrl = null;
|
|
107
|
-
// Timestamp when login mode started. Used for status reporting and timeout calculation.
|
|
108
|
-
let loginStartTime = null;
|
|
109
|
-
// Timeout handle for auto-ending login mode after 15 minutes.
|
|
110
|
-
let loginTimeoutHandle = null;
|
|
111
|
-
// Login timeout duration (15 minutes).
|
|
112
|
-
const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
|
|
100
|
+
// Login mode management. State and functions live in login.ts; re-exported here so existing consumers don't need import path changes. clearLoginState,
|
|
101
|
+
// isLoginModeActive, and setBrowserAccessors are imported above; the first two for internal use, setBrowserAccessors for one-time initialization below.
|
|
102
|
+
export { clearLoginState, isLoginModeActive };
|
|
103
|
+
export { endLoginMode, getLoginPage, getLoginStatus, startLoginMode } from "./login.js";
|
|
104
|
+
// Inject browser accessors into the login module. This breaks the circular dependency (login needs getBrowserInstance/minimizeBrowserWindow, index needs login
|
|
105
|
+
// functions) using the same setter/getter pattern as setChromeUserAgent in chromeFetch.ts. Function declarations are hoisted, so both accessors are available here.
|
|
106
|
+
setBrowserAccessors({ getBrowserInstance, minimizeBrowserWindow });
|
|
113
107
|
/**
|
|
114
108
|
* Computes the current system status and emits it to SSE subscribers. Called when browser state changes significantly or when streams are added/removed.
|
|
115
109
|
*/
|
|
@@ -191,7 +185,7 @@ function getManagedPageId(page) {
|
|
|
191
185
|
export async function ensureDataDirectory() {
|
|
192
186
|
try {
|
|
193
187
|
await fsPromises.mkdir(getDataDir(), { recursive: true });
|
|
194
|
-
LOG.debug("browser", "Data directory ready: %s.", getDataDir());
|
|
188
|
+
LOG.debug("browser:lifecycle", "Data directory ready: %s.", getDataDir());
|
|
195
189
|
}
|
|
196
190
|
catch (error) {
|
|
197
191
|
LOG.error("Failed to create data directory %s: %s.", getDataDir(), formatError(error));
|
|
@@ -212,78 +206,124 @@ export async function ensureDataDirectory() {
|
|
|
212
206
|
* - Extension initialization: The puppeteer-stream extension needs time after browser launch to inject its recording APIs. We wait for this initialization before
|
|
213
207
|
* attempting to capture streams.
|
|
214
208
|
*/
|
|
209
|
+
/**
|
|
210
|
+
* Persists the Chrome process PID to both the module-level variable (fast, in-memory) and a PID file on disk (survives crashes). The PID file allows the next
|
|
211
|
+
* startup to find and terminate orphaned Chrome processes even if the Node process crashed without cleanup.
|
|
212
|
+
* @param pid - The Chrome process ID to save.
|
|
213
|
+
*/
|
|
214
|
+
function saveChromePid(pid) {
|
|
215
|
+
chromePid = pid;
|
|
216
|
+
writePidFile(getChromePidFilePath(), pid, "Chrome", LOG);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Loads the Chrome PID from the module-level variable (fast path) or falls back to reading the PID file on disk (crash recovery path). Returns null if no PID
|
|
220
|
+
* is available — either first run or the PID file was already cleaned up.
|
|
221
|
+
* @returns The Chrome process ID, or null if unavailable.
|
|
222
|
+
*/
|
|
223
|
+
function loadChromePid() {
|
|
224
|
+
return chromePid ?? readPidFile(getChromePidFilePath(), "Chrome", LOG);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Clears the Chrome PID from both the module-level variable and the PID file on disk. Called after successful process cleanup to prevent stale PID reuse.
|
|
228
|
+
*/
|
|
229
|
+
function clearChromePid() {
|
|
230
|
+
chromePid = null;
|
|
231
|
+
clearPidFile(getChromePidFilePath(), "Chrome", LOG);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Synchronous sleep using Atomics.wait(). This is a cross-platform replacement for execSync("sleep N") that works on all platforms without shelling out.
|
|
235
|
+
* Required because killStaleChrome() runs in the synchronous process.on("exit") handler where async operations are not available.
|
|
236
|
+
* @param ms - Duration to sleep in milliseconds.
|
|
237
|
+
*/
|
|
238
|
+
function syncSleep(ms) {
|
|
239
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
240
|
+
}
|
|
215
241
|
/**
|
|
216
242
|
* Ensures a clean slate for browser launch by terminating any stale Chrome processes and removing orphaned profile lock files. Chrome locks its profile directory
|
|
217
|
-
* while running, and if a previous instance crashed without releasing the lock, we cannot launch a new browser with the same profile. This function uses
|
|
218
|
-
* find and terminate
|
|
219
|
-
*
|
|
243
|
+
* while running, and if a previous instance crashed without releasing the lock, we cannot launch a new browser with the same profile. This function uses the
|
|
244
|
+
* saved Chrome PID to find and terminate the process via process.kill(), then polls for exit using signal 0. This approach is fully cross-platform — it does not
|
|
245
|
+
* rely on Unix-only tools like pkill or pgrep.
|
|
220
246
|
*
|
|
221
247
|
* The termination strategy escalates from SIGTERM to SIGKILL. SIGTERM is sent first, giving Chrome up to 5 seconds to flush its profile databases (LevelDB,
|
|
222
248
|
* extension state, session storage) and exit cleanly. If Chrome does not exit, SIGKILL is sent as a fallback. This escalation is critical when called from the
|
|
223
249
|
* process exit handler — Chrome may be running normally (e.g., after a capture probe timeout), and an immediate SIGKILL would corrupt its profile databases,
|
|
224
250
|
* poisoning the Docker volume for subsequent container restarts.
|
|
225
251
|
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
252
|
+
* If no PID is available (first run or clean shutdown where the PID file was already removed), process killing is skipped entirely and only lock file cleanup
|
|
253
|
+
* runs. When a PID file does exist but the process is gone (Docker restart with a mounted volume — the PID belongs to the previous container's PID namespace),
|
|
254
|
+
* process.kill() throws ESRCH, which is caught gracefully.
|
|
228
255
|
*
|
|
229
|
-
* This is called at startup before launching the browser and
|
|
256
|
+
* This is called at startup before launching the browser and from the process exit handler as a crash recovery fallback. It's safe to call even when no stale
|
|
257
|
+
* processes or files exist.
|
|
230
258
|
*/
|
|
231
259
|
export function killStaleChrome() {
|
|
232
|
-
// Build the profile directory path that would appear in Chrome's command-line arguments.
|
|
233
260
|
const profileDir = getChromeDataDir(CONFIG);
|
|
261
|
+
const pid = loadChromePid();
|
|
234
262
|
const POLL_INTERVAL_MS = 200;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
263
|
+
if (pid !== null) {
|
|
264
|
+
try {
|
|
265
|
+
// Send SIGTERM first to give Chrome a chance to flush its profile databases (LevelDB, extension state, session storage) before exiting. This is critical
|
|
266
|
+
// when called from the process exit handler — Chrome may be running normally (e.g., after a capture probe timeout) and SIGKILL would corrupt its profile
|
|
267
|
+
// databases, poisoning the Docker volume for subsequent restarts.
|
|
268
|
+
process.kill(pid, "SIGTERM");
|
|
269
|
+
LOG.debug("browser:lifecycle", "Sent SIGTERM to Chrome process %d.", pid);
|
|
270
|
+
// Wait up to 5 seconds for Chrome to flush its databases and exit after SIGTERM. Containerized environments with software rendering and shared CPU may
|
|
271
|
+
// need the full window.
|
|
272
|
+
const TERM_WAIT_MS = 5000;
|
|
273
|
+
if (!waitForChromeExit(pid, TERM_WAIT_MS, POLL_INTERVAL_MS)) {
|
|
274
|
+
// SIGTERM didn't work. Escalate to SIGKILL. Orphaned Chrome processes (from a crashed parent or previous container) may not respond to SIGTERM.
|
|
275
|
+
LOG.debug("browser:lifecycle", "Chrome did not exit after SIGTERM. Escalating to SIGKILL.");
|
|
276
|
+
try {
|
|
277
|
+
process.kill(pid, "SIGKILL");
|
|
278
|
+
}
|
|
279
|
+
catch (_error) {
|
|
280
|
+
// ESRCH — Chrome exited between the poll check and the kill call.
|
|
281
|
+
}
|
|
282
|
+
const KILL_WAIT_MS = 2000;
|
|
283
|
+
if (!waitForChromeExit(pid, KILL_WAIT_MS, POLL_INTERVAL_MS)) {
|
|
284
|
+
LOG.warn("Chrome process %d did not exit after %dms of signal escalation. Proceeding anyway.", pid, TERM_WAIT_MS + KILL_WAIT_MS);
|
|
285
|
+
}
|
|
252
286
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
// ESRCH means the process does not exist — expected when there are no stale processes from a clean shutdown, or in Docker where the PID belongs to a
|
|
290
|
+
// previous container's PID namespace.
|
|
291
|
+
if (error.code !== "ESRCH") {
|
|
292
|
+
LOG.warn("Failed to signal Chrome process %d: %s.", pid, formatError(error));
|
|
256
293
|
}
|
|
257
294
|
}
|
|
258
|
-
|
|
259
|
-
catch (_error) {
|
|
260
|
-
// When pkill finds no matching processes, it returns a non-zero exit code. This is expected when there are no stale processes from a clean shutdown.
|
|
295
|
+
clearChromePid();
|
|
261
296
|
}
|
|
262
297
|
// Remove stale lock and port files left behind by an unclean Chrome exit.
|
|
263
298
|
cleanStaleProfileFiles(profileDir);
|
|
299
|
+
// Mark this process as owning Chrome cleanup. The exit handler checks this flag to avoid killing Chrome that belongs to another running instance.
|
|
300
|
+
ownsChromeCleanup = true;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Returns whether this process has taken ownership of Chrome cleanup by running killStaleChrome() during startup. Used by the exit handler to avoid killing
|
|
304
|
+
* Chrome that belongs to another running PrismCast instance.
|
|
305
|
+
* @returns True if killStaleChrome() has run in this process.
|
|
306
|
+
*/
|
|
307
|
+
export function canCleanupChrome() {
|
|
308
|
+
return ownsChromeCleanup;
|
|
264
309
|
}
|
|
265
310
|
/**
|
|
266
|
-
* Polls
|
|
267
|
-
*
|
|
268
|
-
* @param
|
|
311
|
+
* Polls until the Chrome process with the given PID has exited, or the timeout expires. Uses process.kill(pid, 0) to check process existence — throws ESRCH
|
|
312
|
+
* when the process is gone. Between polls, sleeps synchronously using Atomics.wait() for cross-platform compatibility.
|
|
313
|
+
* @param pid - The Chrome process ID to wait for.
|
|
269
314
|
* @param timeoutMs - Maximum time to wait in milliseconds.
|
|
270
|
-
* @param pollIntervalMs - Time between
|
|
271
|
-
* @returns True if
|
|
315
|
+
* @param pollIntervalMs - Time between existence checks in milliseconds.
|
|
316
|
+
* @returns True if the process exited within the timeout, false otherwise.
|
|
272
317
|
*/
|
|
273
|
-
function waitForChromeExit(
|
|
318
|
+
function waitForChromeExit(pid, timeoutMs, pollIntervalMs) {
|
|
274
319
|
const deadline = Date.now() + timeoutMs;
|
|
275
320
|
while (Date.now() < deadline) {
|
|
276
|
-
|
|
277
|
-
execSync(["pgrep -f \"", profileDir, "\""].join(""), { stdio: "ignore" });
|
|
278
|
-
// Processes still exist. Wait and check again.
|
|
279
|
-
execSync(["sleep ", String(pollIntervalMs / 1000)].join(""));
|
|
280
|
-
}
|
|
281
|
-
catch (_error) {
|
|
282
|
-
// pgrep returned non-zero — no matching processes remain.
|
|
321
|
+
if (!isProcessRunning(pid)) {
|
|
283
322
|
return true;
|
|
284
323
|
}
|
|
324
|
+
syncSleep(pollIntervalMs);
|
|
285
325
|
}
|
|
286
|
-
return
|
|
326
|
+
return !isProcessRunning(pid);
|
|
287
327
|
}
|
|
288
328
|
/**
|
|
289
329
|
* Removes stale Chrome profile lock files and the DevTools port file. Chrome writes these while running and removes them on clean shutdown, but an unclean exit
|
|
@@ -300,7 +340,7 @@ function cleanStaleProfileFiles(profileDir) {
|
|
|
300
340
|
const filePath = path.join(profileDir, file);
|
|
301
341
|
try {
|
|
302
342
|
fs.unlinkSync(filePath);
|
|
303
|
-
LOG.debug("browser", "Removed stale profile file: %s.", file);
|
|
343
|
+
LOG.debug("browser:lifecycle", "Removed stale profile file: %s.", file);
|
|
304
344
|
}
|
|
305
345
|
catch (error) {
|
|
306
346
|
// ENOENT means the file doesn't exist, which is the expected case after a clean shutdown. Any other error (permissions, filesystem issues) is worth
|
|
@@ -469,6 +509,28 @@ async function launchWithCustomArgs(opts) {
|
|
|
469
509
|
* use by the preset system when determining effective viewport.
|
|
470
510
|
* @param browser - The browser instance to use for detection.
|
|
471
511
|
*/
|
|
512
|
+
/**
|
|
513
|
+
* Formats the GPU capabilities into a human-readable suffix for the "Chrome ready" log line. The renderer string is already cleaned (ANGLE wrapper and Metal
|
|
514
|
+
* prefix stripped) at detection time, so this function uses it directly and appends hardware-accelerated codec names in brackets when available.
|
|
515
|
+
* @param gpu - The detected GPU capabilities.
|
|
516
|
+
* @returns A formatted string like " (GPU: Apple M1 [H264, HEVC])" or " (software rendering)".
|
|
517
|
+
*/
|
|
518
|
+
function formatGpuSuffix(gpu) {
|
|
519
|
+
const codecs = [
|
|
520
|
+
gpu.av1HardwareEncoding && "AV1",
|
|
521
|
+
gpu.h264HardwareEncoding && "H264",
|
|
522
|
+
gpu.hevcHardwareEncoding && "HEVC"
|
|
523
|
+
].filter(Boolean);
|
|
524
|
+
if (codecs.length > 0) {
|
|
525
|
+
return " (GPU: " + gpu.renderer + " [" + codecs.join(", ") + "])";
|
|
526
|
+
}
|
|
527
|
+
// No hardware encoding available. A GPU may be present for rendering but lack hardware encoding — show the GPU name without codecs if we have a non-trivial
|
|
528
|
+
// renderer string, otherwise label as software rendering.
|
|
529
|
+
if (gpu.renderer && (gpu.renderer !== "unknown")) {
|
|
530
|
+
return " (GPU: " + gpu.renderer + ")";
|
|
531
|
+
}
|
|
532
|
+
return " (software rendering)";
|
|
533
|
+
}
|
|
472
534
|
async function detectDisplayDimensions(browser) {
|
|
473
535
|
let tempPage = null;
|
|
474
536
|
let usingTempPage = false;
|
|
@@ -481,24 +543,123 @@ async function detectDisplayDimensions(browser) {
|
|
|
481
543
|
usingTempPage = true;
|
|
482
544
|
targetPage = tempPage;
|
|
483
545
|
}
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
546
|
+
// Ensure the window is in normal state before measuring. Chrome restores window state from the persistent user data directory, so after a scheduled browser
|
|
547
|
+
// restart the window may launch minimized. A minimized window reports outerWidth/outerHeight as 0 while innerWidth/innerHeight retains the viewport dimensions,
|
|
548
|
+
// producing negative chrome measurements that poison all subsequent window sizing.
|
|
549
|
+
await unminimizeWindow(targetPage);
|
|
550
|
+
// Measure display dimensions and browser chrome via JavaScript. The measurement is retried if chrome dimensions are negative, which indicates the macOS window
|
|
551
|
+
// manager has not yet finished the minimize-to-normal state transition (the animation is asynchronous relative to the CDP command).
|
|
552
|
+
let dimensions;
|
|
553
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
554
|
+
// eslint-disable-next-line no-await-in-loop
|
|
555
|
+
dimensions = await evaluateWithAbort(targetPage, () => {
|
|
556
|
+
return {
|
|
557
|
+
// Available screen dimensions (excludes taskbar, dock, menu bar).
|
|
558
|
+
availHeight: screen.availHeight,
|
|
559
|
+
availWidth: screen.availWidth,
|
|
560
|
+
// Browser chrome dimensions (title bar, toolbar, borders).
|
|
561
|
+
chromeHeight: window.outerHeight - window.innerHeight,
|
|
562
|
+
chromeWidth: window.outerWidth - window.innerWidth
|
|
563
|
+
};
|
|
564
|
+
});
|
|
565
|
+
if ((dimensions.chromeWidth >= 0) && (dimensions.chromeHeight >= 0)) {
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
// Chrome dimensions are negative — the window manager is still transitioning. Wait briefly and remeasure.
|
|
569
|
+
if (attempt < 2) {
|
|
570
|
+
LOG.debug("browser:lifecycle", "Display detection measured negative chrome dimensions (%s\u00d7%s, attempt %s). Retrying after window state settles.", dimensions.chromeWidth, dimensions.chromeHeight, attempt + 1);
|
|
571
|
+
// eslint-disable-next-line no-await-in-loop
|
|
572
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// If all attempts produced negative chrome dimensions, skip caching to avoid poisoning window sizing. The preset system will use the configured preset without
|
|
576
|
+
// degradation, and resizeAndMinimizeWindow will fall back to measuring chrome dimensions via page.evaluate() on each call.
|
|
577
|
+
if (!dimensions) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if ((dimensions.chromeWidth < 0) || (dimensions.chromeHeight < 0)) {
|
|
581
|
+
LOG.warn("Display detection produced invalid chrome dimensions after 3 attempts (%s\u00d7%s). Window sizing may be incorrect.", dimensions.chromeWidth, dimensions.chromeHeight);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
495
584
|
// Calculate maximum viewport: available screen space minus browser chrome.
|
|
496
585
|
const maxWidth = dimensions.availWidth - dimensions.chromeWidth;
|
|
497
586
|
const maxHeight = dimensions.availHeight - dimensions.chromeHeight;
|
|
498
587
|
// Cache the results for use by the preset system and window sizing.
|
|
499
588
|
setBrowserChrome(dimensions.chromeWidth, dimensions.chromeHeight);
|
|
500
589
|
setMaxSupportedViewport(maxWidth, maxHeight);
|
|
501
|
-
LOG.debug("browser", "Display detection complete: screen %s\u00d7%s, chrome %s\u00d7%s, max viewport %s\u00d7%s.", dimensions.availWidth, dimensions.availHeight, dimensions.chromeWidth, dimensions.chromeHeight, maxWidth, maxHeight);
|
|
590
|
+
LOG.debug("browser:lifecycle", "Display detection complete: screen %s\u00d7%s, chrome %s\u00d7%s, max viewport %s\u00d7%s.", dimensions.availWidth, dimensions.availHeight, dimensions.chromeWidth, dimensions.chromeHeight, maxWidth, maxHeight);
|
|
591
|
+
// Detect GPU capabilities via CDP SystemInfo.getInfo. This is the authoritative source for GPU identity and hardware encoding capabilities — it runs at the
|
|
592
|
+
// browser level (no page context or secure context required) and returns the actual list of hardware-accelerated video encoding profiles.
|
|
593
|
+
try {
|
|
594
|
+
const cdpSession = await browser.target().createCDPSession();
|
|
595
|
+
try {
|
|
596
|
+
const sysInfo = await cdpSession.send("SystemInfo.getInfo");
|
|
597
|
+
// Extract the GPU renderer from the primary device. The WebGL unmasked renderer provides a richer string (includes ANGLE backend info), so we query
|
|
598
|
+
// that as well and prefer it when available.
|
|
599
|
+
const deviceName = (sysInfo.gpu.devices.length > 0) ? sysInfo.gpu.devices[0].deviceString : "unknown";
|
|
600
|
+
// Get the unmasked WebGL renderer for a more descriptive GPU identity string.
|
|
601
|
+
const webglRenderer = await evaluateWithAbort(targetPage, () => {
|
|
602
|
+
const canvas = document.createElement("canvas");
|
|
603
|
+
const gl = canvas.getContext("webgl");
|
|
604
|
+
if (!gl) {
|
|
605
|
+
return "unknown";
|
|
606
|
+
}
|
|
607
|
+
const ext = gl.getExtension("WEBGL_debug_renderer_info");
|
|
608
|
+
return ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER));
|
|
609
|
+
});
|
|
610
|
+
// Extract the meaningful GPU name. ANGLE wraps the actual GPU identity: "ANGLE (Vendor, GPU Name, API Version)". The GPU name is the second
|
|
611
|
+
// comma-separated field. For non-ANGLE renderers, use the device string from CDP.
|
|
612
|
+
let renderer = webglRenderer;
|
|
613
|
+
const angleMatch = /^ANGLE \([^,]+, ([^,]+)/.exec(webglRenderer);
|
|
614
|
+
if (angleMatch) {
|
|
615
|
+
renderer = angleMatch[1].trim();
|
|
616
|
+
// Strip the "ANGLE Metal Renderer: " prefix that macOS adds.
|
|
617
|
+
const metalPrefix = "ANGLE Metal Renderer: ";
|
|
618
|
+
if (renderer.startsWith(metalPrefix)) {
|
|
619
|
+
renderer = renderer.slice(metalPrefix.length);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
else if ((webglRenderer === "WebKit WebGL") || (webglRenderer === "unknown")) {
|
|
623
|
+
renderer = deviceName;
|
|
624
|
+
}
|
|
625
|
+
// Determine hardware encoding capability. Two paths:
|
|
626
|
+
// 1. featureStatus.video_encode === "enabled" — authoritative Chrome-level flag indicating the platform's hardware encoding framework is active
|
|
627
|
+
// (VideoToolbox on macOS, VA-API on Linux, DXVA on Windows). When enabled, H.264 hardware encoding is always available.
|
|
628
|
+
// 2. videoEncoding profile array — lists specific hardware-accelerated codec profiles (e.g., "H264 Main", "HEVC Main"). Populated on Linux/Windows
|
|
629
|
+
// via VA-API/DXVA but empty on macOS where VideoToolbox doesn't enumerate through this interface.
|
|
630
|
+
const videoEncodeEnabled = sysInfo.gpu.featureStatus?.video_encode === "enabled";
|
|
631
|
+
const h264FromProfiles = sysInfo.gpu.videoEncoding.some((e) => e.profile.startsWith("H264"));
|
|
632
|
+
const hevcFromProfiles = sysInfo.gpu.videoEncoding.some((e) => e.profile.startsWith("HEVC"));
|
|
633
|
+
const av1FromProfiles = sysInfo.gpu.videoEncoding.some((e) => e.profile.startsWith("AV1"));
|
|
634
|
+
// H.264 hardware encoding is available when either the feature flag or the profile list confirms it.
|
|
635
|
+
const h264Hardware = videoEncodeEnabled || h264FromProfiles;
|
|
636
|
+
// HEVC and AV1 hardware encoding: check the profile list first (authoritative on Linux/Windows). On macOS (empty profile list), probe via MediaRecorder
|
|
637
|
+
// in the page context — MediaRecorder.isTypeSupported works in non-secure contexts unlike VideoEncoder.
|
|
638
|
+
let hevcHardware = hevcFromProfiles;
|
|
639
|
+
let av1Hardware = av1FromProfiles;
|
|
640
|
+
if (videoEncodeEnabled && (!hevcHardware || !av1Hardware)) {
|
|
641
|
+
const [hevcSupported, av1Supported] = await evaluateWithAbort(targetPage, () => {
|
|
642
|
+
if (typeof MediaRecorder === "undefined") {
|
|
643
|
+
return [false, false];
|
|
644
|
+
}
|
|
645
|
+
return [
|
|
646
|
+
MediaRecorder.isTypeSupported("video/mp4;codecs=hvc1.1.6.L93.B0"),
|
|
647
|
+
MediaRecorder.isTypeSupported("video/mp4;codecs=av01.0.08M.08")
|
|
648
|
+
];
|
|
649
|
+
});
|
|
650
|
+
hevcHardware ||= hevcSupported;
|
|
651
|
+
av1Hardware ||= av1Supported;
|
|
652
|
+
}
|
|
653
|
+
setGpuCapabilities({ av1HardwareEncoding: av1Hardware, h264HardwareEncoding: h264Hardware, hevcHardwareEncoding: hevcHardware, renderer });
|
|
654
|
+
LOG.debug("browser:lifecycle", "GPU detection: device=%s, renderer=%s, H.264=%s, HEVC=%s, AV1=%s, video_encode=%s, encoding profiles=%s.", deviceName, renderer, h264Hardware, hevcHardware, av1Hardware, sysInfo.gpu.featureStatus?.video_encode ?? "unknown", sysInfo.gpu.videoEncoding.map((e) => e.profile).join(", ") || "none");
|
|
655
|
+
}
|
|
656
|
+
finally {
|
|
657
|
+
void cdpSession.detach().catch(() => { });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
catch (gpuError) {
|
|
661
|
+
LOG.debug("browser:lifecycle", "GPU detection failed: %s.", String(gpuError));
|
|
662
|
+
}
|
|
502
663
|
// Check if the configured preset needs to be degraded and warn the user.
|
|
503
664
|
const presetResult = getEffectivePreset(CONFIG);
|
|
504
665
|
if (presetResult.degraded && presetResult.maxViewport) {
|
|
@@ -533,10 +694,12 @@ async function detectDisplayDimensions(browser) {
|
|
|
533
694
|
* This ensures streams fail gracefully rather than hanging indefinitely trying to use closed pages.
|
|
534
695
|
*/
|
|
535
696
|
function handleBrowserDisconnect() {
|
|
536
|
-
// Clear the browser reference, launch timestamp,
|
|
697
|
+
// Clear the browser reference, launch timestamp, cached version, user agent, and stale PID so getCurrentBrowser() will launch a new instance on the next call.
|
|
537
698
|
currentBrowser = null;
|
|
538
699
|
browserLaunchTime = null;
|
|
700
|
+
chromePid = null;
|
|
539
701
|
currentChromeVersion = null;
|
|
702
|
+
setChromeUserAgent(null);
|
|
540
703
|
// Cancel any pending restart quiet timer since the browser is already gone.
|
|
541
704
|
if (restartQuietTimer) {
|
|
542
705
|
clearTimeout(restartQuietTimer);
|
|
@@ -544,20 +707,10 @@ function handleBrowserDisconnect() {
|
|
|
544
707
|
}
|
|
545
708
|
// Clear all channel selection caches. Cached state (guide row positions, discovered page URLs) may be stale in a new browser session.
|
|
546
709
|
clearChannelSelectionCaches();
|
|
547
|
-
// Clear login state if login mode was active. We
|
|
548
|
-
// attempt any browser operations.
|
|
549
|
-
if (
|
|
550
|
-
|
|
551
|
-
clearTimeout(loginTimeoutHandle);
|
|
552
|
-
loginTimeoutHandle = null;
|
|
553
|
-
}
|
|
554
|
-
loginModeActive = false;
|
|
555
|
-
loginPage = null;
|
|
556
|
-
loginUrl = null;
|
|
557
|
-
loginStartTime = null;
|
|
558
|
-
if (!gracefulShutdownInProgress) {
|
|
559
|
-
LOG.info("Login mode ended due to browser disconnect.");
|
|
560
|
-
}
|
|
710
|
+
// Clear login state if login mode was active. We use clearLoginState() rather than endLoginMode() because the browser is already gone and we don't want to
|
|
711
|
+
// attempt any browser operations (page close, window minimize).
|
|
712
|
+
if (clearLoginState() && !gracefulShutdownInProgress) {
|
|
713
|
+
LOG.info("Login mode ended due to browser disconnect.");
|
|
561
714
|
}
|
|
562
715
|
// Only log the error for unexpected disconnects. During graceful shutdown, closeBrowser() set the flag and this disconnect is intentional.
|
|
563
716
|
if (!gracefulShutdownInProgress) {
|
|
@@ -619,6 +772,15 @@ async function launchBrowser() {
|
|
|
619
772
|
// The launch function from puppeteer-stream wraps standard Puppeteer launch to inject the streaming extension. We pass our custom launch function that
|
|
620
773
|
// handles packaged executable extension paths.
|
|
621
774
|
currentBrowser = await launch({ launch: launchWithCustomArgs }, options);
|
|
775
|
+
// Persist the Chrome PID for cross-platform process cleanup. The PID file survives Node crashes, allowing the next startup to find and terminate orphaned
|
|
776
|
+
// Chrome processes without relying on Unix-only tools like pkill/pgrep.
|
|
777
|
+
const launchedPid = currentBrowser.process()?.pid;
|
|
778
|
+
if (launchedPid) {
|
|
779
|
+
saveChromePid(launchedPid);
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
LOG.warn("Chrome process PID is unavailable. Orphaned process cleanup after a crash will be limited to lock file removal.");
|
|
783
|
+
}
|
|
622
784
|
// Register a handler for browser disconnection. This ensures we clean up properly if the browser crashes or is closed unexpectedly.
|
|
623
785
|
currentBrowser.on("disconnected", handleBrowserDisconnect);
|
|
624
786
|
LOG.debug("timing:browser", "Chrome process spawned. (+%sms)", browserElapsed());
|
|
@@ -640,11 +802,15 @@ async function launchBrowser() {
|
|
|
640
802
|
await detectDisplayDimensions(currentBrowser);
|
|
641
803
|
LOG.debug("timing:browser", "Display detection complete. (+%sms)", browserElapsed());
|
|
642
804
|
// Log the Chrome version for diagnostic reference. This helps correlate browser behavior changes (tab unresponsiveness, memory pressure, capture issues)
|
|
643
|
-
// with specific Chrome releases.
|
|
805
|
+
// with specific Chrome releases. We also capture the User-Agent string so that server-side fetch() calls to provider CDNs can match Chrome's identity.
|
|
644
806
|
const chromeVersion = await currentBrowser.version();
|
|
807
|
+
const userAgent = await currentBrowser.userAgent();
|
|
645
808
|
browserLaunchTime = Date.now();
|
|
646
809
|
currentChromeVersion = chromeVersion;
|
|
647
|
-
|
|
810
|
+
setChromeUserAgent(userAgent);
|
|
811
|
+
const gpu = getGpuCapabilities();
|
|
812
|
+
const gpuSuffix = gpu ? formatGpuSuffix(gpu) : "";
|
|
813
|
+
LOG.info("Chrome ready: %s%s.", chromeVersion, gpuSuffix);
|
|
648
814
|
LOG.debug("timing:browser", "Browser ready. Total: %sms.", browserElapsed());
|
|
649
815
|
// Emit system status update for SSE subscribers.
|
|
650
816
|
await emitCurrentSystemStatus();
|
|
@@ -654,10 +820,11 @@ async function launchBrowser() {
|
|
|
654
820
|
}
|
|
655
821
|
catch (error) {
|
|
656
822
|
LOG.error("Failed to launch browser: %s.", formatError(error));
|
|
657
|
-
// Clear the browser reference, launch timestamp,
|
|
823
|
+
// Clear the browser reference, launch timestamp, cached version, and user agent on failure so the next call will attempt to launch again.
|
|
658
824
|
currentBrowser = null;
|
|
659
825
|
browserLaunchTime = null;
|
|
660
826
|
currentChromeVersion = null;
|
|
827
|
+
setChromeUserAgent(null);
|
|
661
828
|
throw error;
|
|
662
829
|
}
|
|
663
830
|
return currentBrowser;
|
|
@@ -669,6 +836,14 @@ async function launchBrowser() {
|
|
|
669
836
|
export function getChromeVersion() {
|
|
670
837
|
return currentChromeVersion;
|
|
671
838
|
}
|
|
839
|
+
/**
|
|
840
|
+
* Returns the current browser instance, or null if not launched. Unlike getCurrentBrowser(), this does not lazily launch. Used by modules that need to check
|
|
841
|
+
* browser state without triggering a launch (e.g., login mode checking connectivity before opening a tab).
|
|
842
|
+
* @returns The browser instance, or null if not running.
|
|
843
|
+
*/
|
|
844
|
+
export function getBrowserInstance() {
|
|
845
|
+
return currentBrowser;
|
|
846
|
+
}
|
|
672
847
|
/**
|
|
673
848
|
* Checks if the browser is currently connected and usable. This is a synchronous check that can be used before attempting browser operations.
|
|
674
849
|
* @returns True if the browser is connected and ready for use, false otherwise.
|
|
@@ -724,7 +899,7 @@ export async function minimizeBrowserWindow() {
|
|
|
724
899
|
}
|
|
725
900
|
}
|
|
726
901
|
// Resizing/minimizing is not critical - log a warning but don't fail the operation.
|
|
727
|
-
LOG.debug("browser", "Could not resize and minimize browser window: %s.", formatError(error));
|
|
902
|
+
LOG.debug("browser:lifecycle", "Could not resize and minimize browser window: %s.", formatError(error));
|
|
728
903
|
}
|
|
729
904
|
}
|
|
730
905
|
/**
|
|
@@ -748,179 +923,64 @@ export async function getBrowserPages() {
|
|
|
748
923
|
* Closes the browser and cleans up resources. This is called during graceful shutdown to ensure Chrome exits cleanly. After this call, the browser reference is
|
|
749
924
|
* cleared and any subsequent stream requests will launch a fresh browser.
|
|
750
925
|
*
|
|
751
|
-
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
926
|
+
* Chrome termination uses Puppeteer's ChildProcess handle and its `exit` event for detection:
|
|
927
|
+
*
|
|
928
|
+
* - browserRef.close() sends CDP Browser.close and waits for WebSocket teardown, which hangs 3-5 seconds even after Chrome exits.
|
|
929
|
+
* - browserRef.disconnect() drops the WebSocket instantly but orphans Chrome as a Node child process, creating a zombie that process.kill(pid, 0) cannot detect.
|
|
930
|
+
* - Synchronous polling (Atomics.wait) blocks the event loop, preventing Node from processing SIGCHLD to reap the child — Chrome becomes a zombie regardless
|
|
931
|
+
* of how SIGTERM was sent.
|
|
932
|
+
*
|
|
933
|
+
* Instead, we send SIGTERM through the ChildProcess handle and listen for the `exit` event. This keeps the event loop running so Node can process SIGCHLD and
|
|
934
|
+
* reap Chrome properly. The exit event fires only after the process is fully reaped — no zombies, no polling, no event loop blocking.
|
|
754
935
|
*/
|
|
755
936
|
export async function closeBrowser() {
|
|
756
937
|
// Ensure the flag is set so the disconnect handler knows this is intentional. Normally set earlier by app.ts shutdown(), but set here as a fallback for direct
|
|
757
938
|
// calls to closeBrowser().
|
|
758
939
|
setGracefulShutdown(true);
|
|
759
940
|
const browserRef = currentBrowser;
|
|
760
|
-
// Clear the reference, launch timestamp,
|
|
941
|
+
// Clear the reference, launch timestamp, cached version, and user agent early to prevent any new operations from using it.
|
|
761
942
|
currentBrowser = null;
|
|
762
943
|
browserLaunchTime = null;
|
|
763
944
|
currentChromeVersion = null;
|
|
945
|
+
setChromeUserAgent(null);
|
|
764
946
|
if (!browserRef) {
|
|
765
947
|
return;
|
|
766
948
|
}
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
if (message.includes("timed out")) {
|
|
778
|
-
LOG.warn("Browser did not close within 5 seconds. Forcing termination.");
|
|
779
|
-
}
|
|
780
|
-
else {
|
|
781
|
-
LOG.debug("browser", "Browser close error: %s.", message);
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
// Stage 2: Catch anything Stage 1 missed. If Chrome didn't respond to the DevTools close command (broken WebSocket, hung process), killStaleChrome()
|
|
786
|
-
// sends SIGTERM first to give Chrome a chance to flush its profile databases, then escalates to SIGKILL if needed.
|
|
787
|
-
killStaleChrome();
|
|
788
|
-
}
|
|
789
|
-
/* These functions manage the login mode workflow, allowing users to authenticate with TV providers through the browser. The workflow is:
|
|
790
|
-
*
|
|
791
|
-
* 1. User clicks "Login" on a channel in the web UI
|
|
792
|
-
* 2. startLoginMode() opens a new tab with the channel's URL and un-minimizes the browser
|
|
793
|
-
* 3. User completes authentication in the visible browser window
|
|
794
|
-
* 4. User clicks "Done" in the web UI, or closes the tab, or the 15-minute timeout fires
|
|
795
|
-
* 5. endLoginMode() closes the login tab (if still open) and re-minimizes the browser
|
|
796
|
-
*
|
|
797
|
-
* During login mode, new stream requests are blocked to prevent the browser from navigating away or creating conflicting tabs.
|
|
798
|
-
*/
|
|
799
|
-
/**
|
|
800
|
-
* Starts login mode by opening a new browser tab with the specified URL and un-minimizing the browser window. The user can then authenticate with their TV
|
|
801
|
-
* provider in the visible browser.
|
|
802
|
-
*
|
|
803
|
-
* Login mode blocks new stream requests until it ends (via endLoginMode, tab close detection, or timeout).
|
|
804
|
-
* @param url - The URL to navigate to for authentication.
|
|
805
|
-
* @returns Object indicating success or failure with optional error message.
|
|
806
|
-
*/
|
|
807
|
-
export async function startLoginMode(url) {
|
|
808
|
-
// Check if login mode is already active.
|
|
809
|
-
if (loginModeActive) {
|
|
810
|
-
return { error: "Login is already in progress.", success: false };
|
|
811
|
-
}
|
|
812
|
-
// Ensure browser is available.
|
|
813
|
-
if (!currentBrowser?.connected) {
|
|
814
|
-
return { error: "Browser is not connected.", success: false };
|
|
815
|
-
}
|
|
816
|
-
try {
|
|
817
|
-
// Create a new page for login. We intentionally do NOT register it as a managed page so stale page cleanup ignores it.
|
|
818
|
-
loginPage = await currentBrowser.newPage();
|
|
819
|
-
// Set up handler for tab close detection. If the user closes the tab manually, we should end login mode automatically.
|
|
820
|
-
loginPage.on("close", () => {
|
|
821
|
-
// Only auto-end if this is still the active login page.
|
|
822
|
-
if (loginModeActive && loginPage) {
|
|
823
|
-
LOG.info("Login tab was closed. Ending login mode.");
|
|
824
|
-
// Use void to handle the promise without awaiting (we're in an event handler).
|
|
825
|
-
void endLoginMode();
|
|
826
|
-
}
|
|
949
|
+
// Send SIGTERM through Puppeteer's ChildProcess handle and wait for the `exit` event. The ChildProcess handle is only available when Puppeteer launched
|
|
950
|
+
// Chrome (not when connecting to an existing browser), but PrismCast always launches Chrome directly.
|
|
951
|
+
const chromeProcess = browserRef.process();
|
|
952
|
+
if (chromeProcess?.pid && !chromeProcess.killed) {
|
|
953
|
+
const TERM_WAIT_MS = 5000;
|
|
954
|
+
const KILL_WAIT_MS = 2000;
|
|
955
|
+
// Listen for the exit event before sending the signal. The event fires after the OS reaps the process, so there is no zombie window. Resolves to true so
|
|
956
|
+
// Promise.race can distinguish exit from timeout.
|
|
957
|
+
const exitPromise = new Promise((resolve) => {
|
|
958
|
+
chromeProcess.on("exit", () => { resolve(true); });
|
|
827
959
|
});
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
return { success: true };
|
|
843
|
-
}
|
|
844
|
-
catch (error) {
|
|
845
|
-
// Clean up on failure.
|
|
846
|
-
if (loginPage && !loginPage.isClosed()) {
|
|
847
|
-
try {
|
|
848
|
-
await loginPage.close();
|
|
849
|
-
}
|
|
850
|
-
catch (_closeError) {
|
|
851
|
-
// Ignore close errors.
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
loginPage = null;
|
|
855
|
-
return { error: formatError(error), success: false };
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
/**
|
|
859
|
-
* Ends login mode by closing the login tab (if still open) and re-minimizing the browser window. This function is idempotent - it's safe to call multiple times
|
|
860
|
-
* or when login mode is not active.
|
|
861
|
-
*
|
|
862
|
-
* Called by:
|
|
863
|
-
* - User clicking "Done" in the web UI (POST /auth/done)
|
|
864
|
-
* - Tab close detection (user closes the tab manually)
|
|
865
|
-
* - 15-minute timeout
|
|
866
|
-
* - Browser disconnect handler (cleanup)
|
|
867
|
-
*/
|
|
868
|
-
export async function endLoginMode() {
|
|
869
|
-
// Clear the timeout if it hasn't fired yet.
|
|
870
|
-
if (loginTimeoutHandle) {
|
|
871
|
-
clearTimeout(loginTimeoutHandle);
|
|
872
|
-
loginTimeoutHandle = null;
|
|
873
|
-
}
|
|
874
|
-
// Close the login page if it's still open.
|
|
875
|
-
if (loginPage && !loginPage.isClosed()) {
|
|
876
|
-
try {
|
|
877
|
-
await loginPage.close();
|
|
878
|
-
}
|
|
879
|
-
catch (_error) {
|
|
880
|
-
// Ignore close errors - the page may have already been closed.
|
|
960
|
+
chromeProcess.kill("SIGTERM");
|
|
961
|
+
LOG.debug("browser:lifecycle", "Sent SIGTERM to Chrome process %d.", chromeProcess.pid);
|
|
962
|
+
// Wait for Chrome to exit after SIGTERM, with a timeout. If Chrome doesn't exit in time, escalate to SIGKILL.
|
|
963
|
+
const termTimeout = cancellableTimeout(TERM_WAIT_MS);
|
|
964
|
+
const exitedAfterTerm = await Promise.race([exitPromise, termTimeout.promise]);
|
|
965
|
+
termTimeout.cancel();
|
|
966
|
+
if (!exitedAfterTerm) {
|
|
967
|
+
// SIGTERM didn't work within the timeout. Escalate to SIGKILL. Orphaned Chrome processes (from a crashed parent or previous container) may not
|
|
968
|
+
// respond to SIGTERM.
|
|
969
|
+
LOG.debug("browser:lifecycle", "Chrome did not exit after SIGTERM. Escalating to SIGKILL.");
|
|
970
|
+
chromeProcess.kill("SIGKILL");
|
|
971
|
+
const killTimeout = cancellableTimeout(KILL_WAIT_MS);
|
|
972
|
+
await Promise.race([exitPromise, killTimeout.promise]);
|
|
973
|
+
killTimeout.cancel();
|
|
881
974
|
}
|
|
882
975
|
}
|
|
883
|
-
//
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
loginUrl = null;
|
|
888
|
-
loginStartTime = null;
|
|
889
|
-
// Re-minimize the browser window.
|
|
890
|
-
if (wasActive && currentBrowser?.connected) {
|
|
891
|
-
await minimizeBrowserWindow();
|
|
892
|
-
}
|
|
893
|
-
if (wasActive) {
|
|
894
|
-
LOG.info("Login mode ended.");
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
/**
|
|
898
|
-
* Returns whether login mode is currently active. Used by the stream handler to block new stream requests during login.
|
|
899
|
-
* @returns True if login mode is active, false otherwise.
|
|
900
|
-
*/
|
|
901
|
-
export function isLoginModeActive() {
|
|
902
|
-
return loginModeActive;
|
|
903
|
-
}
|
|
904
|
-
/**
|
|
905
|
-
* Returns the current login status including whether active, the URL being used, and when login started. Used by the /auth/status API endpoint.
|
|
906
|
-
* @returns Login status object.
|
|
907
|
-
*/
|
|
908
|
-
export function getLoginStatus() {
|
|
909
|
-
return {
|
|
910
|
-
active: loginModeActive,
|
|
911
|
-
startTime: loginStartTime,
|
|
912
|
-
url: loginUrl
|
|
913
|
-
};
|
|
914
|
-
}
|
|
915
|
-
/**
|
|
916
|
-
* Returns the active login page if login mode is currently active. Used by the profile test flow to evaluate CSS selectors against the live page DOM.
|
|
917
|
-
* @returns The login page, or null if login mode is not active.
|
|
918
|
-
*/
|
|
919
|
-
export function getLoginPage() {
|
|
920
|
-
if (!loginModeActive) {
|
|
921
|
-
return null;
|
|
976
|
+
// Disconnect the Puppeteer WebSocket after Chrome has exited. This cleans up Puppeteer's internal state (event listeners, pending CDP calls) without
|
|
977
|
+
// waiting for the WebSocket close handshake to complete on a dead connection.
|
|
978
|
+
if (browserRef.connected) {
|
|
979
|
+
void browserRef.disconnect();
|
|
922
980
|
}
|
|
923
|
-
|
|
981
|
+
// Clear the PID file and remove stale Chrome profile lock files.
|
|
982
|
+
clearChromePid();
|
|
983
|
+
cleanStaleProfileFiles(getChromeDataDir(CONFIG));
|
|
924
984
|
}
|
|
925
985
|
/* Over time, browser pages (tabs) may accumulate if cleanup fails during stream termination. This can happen due to race conditions, errors during cleanup, or
|
|
926
986
|
* edge cases in stream lifecycle management. Each orphaned page consumes memory and may continue running JavaScript, so we periodically clean them up.
|
|
@@ -962,9 +1022,11 @@ export async function cleanupStalePages() {
|
|
|
962
1022
|
// Build a set of page IDs for pages currently in use by active streams.
|
|
963
1023
|
const activePageIds = new Set();
|
|
964
1024
|
for (const streamInfo of getAllStreams()) {
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1025
|
+
if (streamInfo.page) {
|
|
1026
|
+
const pageId = getManagedPageId(streamInfo.page);
|
|
1027
|
+
if (pageId) {
|
|
1028
|
+
activePageIds.add(pageId);
|
|
1029
|
+
}
|
|
968
1030
|
}
|
|
969
1031
|
}
|
|
970
1032
|
const now = Date.now();
|
|
@@ -1029,12 +1091,12 @@ export async function cleanupStalePages() {
|
|
|
1029
1091
|
}
|
|
1030
1092
|
// Log only if we actually closed something, to avoid log spam from idle cleanup runs.
|
|
1031
1093
|
if (closedCount > 0) {
|
|
1032
|
-
LOG.debug("browser", "Cleaned up %s stale page(s).", closedCount);
|
|
1094
|
+
LOG.debug("browser:lifecycle", "Cleaned up %s stale page(s).", closedCount);
|
|
1033
1095
|
}
|
|
1034
1096
|
}
|
|
1035
1097
|
catch (error) {
|
|
1036
1098
|
// Cleanup failure is not critical - log a warning and try again next interval.
|
|
1037
|
-
LOG.debug("browser", "Stale page cleanup failed: %s.", formatError(error));
|
|
1099
|
+
LOG.debug("browser:lifecycle", "Stale page cleanup failed: %s.", formatError(error));
|
|
1038
1100
|
}
|
|
1039
1101
|
}
|
|
1040
1102
|
/**
|
|
@@ -1065,7 +1127,7 @@ export function stopStalePageCleanup() {
|
|
|
1065
1127
|
*/
|
|
1066
1128
|
function checkBrowserRestart() {
|
|
1067
1129
|
// Skip if the server is shutting down, login mode is active, or the browser is not connected.
|
|
1068
|
-
if (gracefulShutdownInProgress ||
|
|
1130
|
+
if (gracefulShutdownInProgress || isLoginModeActive() || !currentBrowser || !currentBrowser.connected || !browserLaunchTime) {
|
|
1069
1131
|
return;
|
|
1070
1132
|
}
|
|
1071
1133
|
// Skip if the browser has not exceeded the maximum age.
|
|
@@ -1076,7 +1138,7 @@ function checkBrowserRestart() {
|
|
|
1076
1138
|
// If there are active streams, cancel any pending quiet timer and return. Streams that start during the quiet period reset the countdown.
|
|
1077
1139
|
if (getStreamCount() > 0) {
|
|
1078
1140
|
if (restartQuietTimer) {
|
|
1079
|
-
LOG.debug("browser", "Browser restart quiet period cancelled — streams are active.");
|
|
1141
|
+
LOG.debug("browser:lifecycle", "Browser restart quiet period cancelled — streams are active.");
|
|
1080
1142
|
clearTimeout(restartQuietTimer);
|
|
1081
1143
|
restartQuietTimer = null;
|
|
1082
1144
|
}
|
|
@@ -1084,7 +1146,7 @@ function checkBrowserRestart() {
|
|
|
1084
1146
|
}
|
|
1085
1147
|
// No active streams and the browser is old enough. Start the quiet timer if one is not already running.
|
|
1086
1148
|
if (!restartQuietTimer) {
|
|
1087
|
-
LOG.debug("browser", "Browser uptime exceeds threshold. Quiet period started — restart will proceed if no streams start within %s minutes.", Math.round(BROWSER_RESTART_QUIET_PERIOD / 60000));
|
|
1149
|
+
LOG.debug("browser:lifecycle", "Browser uptime exceeds threshold. Quiet period started — restart will proceed if no streams start within %s minutes.", Math.round(BROWSER_RESTART_QUIET_PERIOD / 60000));
|
|
1088
1150
|
restartQuietTimer = setTimeout(() => {
|
|
1089
1151
|
void executeBrowserRestart();
|
|
1090
1152
|
}, BROWSER_RESTART_QUIET_PERIOD);
|
|
@@ -1099,8 +1161,8 @@ async function executeBrowserRestart() {
|
|
|
1099
1161
|
restartQuietTimer = null;
|
|
1100
1162
|
// Final guard: re-check all preconditions. Conditions may have changed during the quiet period (e.g., a stream started just before the timer fired, login
|
|
1101
1163
|
// mode was activated, or the browser disconnected on its own).
|
|
1102
|
-
if (gracefulShutdownInProgress ||
|
|
1103
|
-
LOG.debug("browser", "Browser restart aborted — preconditions no longer met.");
|
|
1164
|
+
if (gracefulShutdownInProgress || isLoginModeActive() || (getStreamCount() > 0) || !currentBrowser || !currentBrowser.connected || !browserLaunchTime) {
|
|
1165
|
+
LOG.debug("browser:lifecycle", "Browser restart aborted — preconditions no longer met.");
|
|
1104
1166
|
return;
|
|
1105
1167
|
}
|
|
1106
1168
|
const age = Date.now() - browserLaunchTime;
|
|
@@ -1108,7 +1170,7 @@ async function executeBrowserRestart() {
|
|
|
1108
1170
|
const minutes = Math.floor((age % 3600000) / 60000);
|
|
1109
1171
|
LOG.info("Restarting browser for scheduled maintenance (uptime: %sh %sm).", hours, minutes);
|
|
1110
1172
|
try {
|
|
1111
|
-
// closeBrowser() sets gracefulShutdownInProgress = true internally and performs
|
|
1173
|
+
// closeBrowser() sets gracefulShutdownInProgress = true internally and performs SIGTERM-based Chrome termination.
|
|
1112
1174
|
await closeBrowser();
|
|
1113
1175
|
// Reset the flag since the server is NOT shutting down — only the browser is restarting.
|
|
1114
1176
|
setGracefulShutdown(false);
|
|
@@ -1193,7 +1255,7 @@ export async function prepareExtension() {
|
|
|
1193
1255
|
throw error;
|
|
1194
1256
|
}
|
|
1195
1257
|
}
|
|
1196
|
-
LOG.debug("browser", "Extension files prepared successfully.");
|
|
1258
|
+
LOG.debug("browser:lifecycle", "Extension files prepared successfully.");
|
|
1197
1259
|
}
|
|
1198
1260
|
catch (error) {
|
|
1199
1261
|
LOG.error("Extension preparation failed: %s.", formatError(error));
|