@bnhf/prismcast 1.3.4-2026.2.19
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/LICENSE.md +7 -0
- package/README.md +347 -0
- package/bin/prismcast +6 -0
- package/dist/app.d.ts +6 -0
- package/dist/app.js +315 -0
- package/dist/app.js.map +1 -0
- package/dist/browser/cdp.d.ts +38 -0
- package/dist/browser/cdp.js +155 -0
- package/dist/browser/cdp.js.map +1 -0
- package/dist/browser/channelSelection.d.ts +65 -0
- package/dist/browser/channelSelection.js +202 -0
- package/dist/browser/channelSelection.js.map +1 -0
- package/dist/browser/display.d.ts +34 -0
- package/dist/browser/display.js +54 -0
- package/dist/browser/display.js.map +1 -0
- package/dist/browser/index.d.ts +205 -0
- package/dist/browser/index.js +1205 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/tuning/fox.d.ts +2 -0
- package/dist/browser/tuning/fox.js +83 -0
- package/dist/browser/tuning/fox.js.map +1 -0
- package/dist/browser/tuning/hbo.d.ts +2 -0
- package/dist/browser/tuning/hbo.js +237 -0
- package/dist/browser/tuning/hbo.js.map +1 -0
- package/dist/browser/tuning/hulu.d.ts +2 -0
- package/dist/browser/tuning/hulu.js +550 -0
- package/dist/browser/tuning/hulu.js.map +1 -0
- package/dist/browser/tuning/sling.d.ts +2 -0
- package/dist/browser/tuning/sling.js +518 -0
- package/dist/browser/tuning/sling.js.map +1 -0
- package/dist/browser/tuning/thumbnailRow.d.ts +2 -0
- package/dist/browser/tuning/thumbnailRow.js +108 -0
- package/dist/browser/tuning/thumbnailRow.js.map +1 -0
- package/dist/browser/tuning/tileClick.d.ts +2 -0
- package/dist/browser/tuning/tileClick.js +103 -0
- package/dist/browser/tuning/tileClick.js.map +1 -0
- package/dist/browser/tuning/youtubeTv.d.ts +2 -0
- package/dist/browser/tuning/youtubeTv.js +182 -0
- package/dist/browser/tuning/youtubeTv.js.map +1 -0
- package/dist/browser/video.d.ts +289 -0
- package/dist/browser/video.js +996 -0
- package/dist/browser/video.js.map +1 -0
- package/dist/channels/index.d.ts +3 -0
- package/dist/channels/index.js +392 -0
- package/dist/channels/index.js.map +1 -0
- package/dist/config/index.d.ts +53 -0
- package/dist/config/index.js +233 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/presets.d.ts +98 -0
- package/dist/config/presets.js +241 -0
- package/dist/config/presets.js.map +1 -0
- package/dist/config/profiles.d.ts +79 -0
- package/dist/config/profiles.js +245 -0
- package/dist/config/profiles.js.map +1 -0
- package/dist/config/providers.d.ts +120 -0
- package/dist/config/providers.js +450 -0
- package/dist/config/providers.js.map +1 -0
- package/dist/config/sites.d.ts +22 -0
- package/dist/config/sites.js +377 -0
- package/dist/config/sites.js.map +1 -0
- package/dist/config/userChannels.d.ts +178 -0
- package/dist/config/userChannels.js +543 -0
- package/dist/config/userChannels.js.map +1 -0
- package/dist/config/userConfig.d.ts +235 -0
- package/dist/config/userConfig.js +913 -0
- package/dist/config/userConfig.js.map +1 -0
- package/dist/hdhr/channelMap.d.ts +21 -0
- package/dist/hdhr/channelMap.js +82 -0
- package/dist/hdhr/channelMap.js.map +1 -0
- package/dist/hdhr/deviceId.d.ts +11 -0
- package/dist/hdhr/deviceId.js +84 -0
- package/dist/hdhr/deviceId.js.map +1 -0
- package/dist/hdhr/discover.d.ts +6 -0
- package/dist/hdhr/discover.js +155 -0
- package/dist/hdhr/discover.js.map +1 -0
- package/dist/hdhr/index.d.ts +9 -0
- package/dist/hdhr/index.js +87 -0
- package/dist/hdhr/index.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +144 -0
- package/dist/index.js.map +1 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +79 -0
- package/dist/routes/assets.js.map +1 -0
- package/dist/routes/auth.d.ts +6 -0
- package/dist/routes/auth.js +77 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/channels.d.ts +6 -0
- package/dist/routes/channels.js +40 -0
- package/dist/routes/channels.js.map +1 -0
- package/dist/routes/components.d.ts +138 -0
- package/dist/routes/components.js +210 -0
- package/dist/routes/components.js.map +1 -0
- package/dist/routes/config.d.ts +72 -0
- package/dist/routes/config.js +1977 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/debug.d.ts +6 -0
- package/dist/routes/debug.js +274 -0
- package/dist/routes/debug.js.map +1 -0
- package/dist/routes/health.d.ts +6 -0
- package/dist/routes/health.js +85 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/hls.d.ts +6 -0
- package/dist/routes/hls.js +25 -0
- package/dist/routes/hls.js.map +1 -0
- package/dist/routes/index.d.ts +19 -0
- package/dist/routes/index.js +49 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/logs.d.ts +6 -0
- package/dist/routes/logs.js +164 -0
- package/dist/routes/logs.js.map +1 -0
- package/dist/routes/mpegts.d.ts +6 -0
- package/dist/routes/mpegts.js +19 -0
- package/dist/routes/mpegts.js.map +1 -0
- package/dist/routes/play.d.ts +6 -0
- package/dist/routes/play.js +18 -0
- package/dist/routes/play.js.map +1 -0
- package/dist/routes/playlist.d.ts +36 -0
- package/dist/routes/playlist.js +134 -0
- package/dist/routes/playlist.js.map +1 -0
- package/dist/routes/root.d.ts +6 -0
- package/dist/routes/root.js +2920 -0
- package/dist/routes/root.js.map +1 -0
- package/dist/routes/streams.d.ts +6 -0
- package/dist/routes/streams.js +88 -0
- package/dist/routes/streams.js.map +1 -0
- package/dist/routes/theme.d.ts +15 -0
- package/dist/routes/theme.js +275 -0
- package/dist/routes/theme.js.map +1 -0
- package/dist/routes/ui.d.ts +56 -0
- package/dist/routes/ui.js +354 -0
- package/dist/routes/ui.js.map +1 -0
- package/dist/service/commands.d.ts +41 -0
- package/dist/service/commands.js +391 -0
- package/dist/service/commands.js.map +1 -0
- package/dist/service/generators.d.ts +33 -0
- package/dist/service/generators.js +432 -0
- package/dist/service/generators.js.map +1 -0
- package/dist/service/index.d.ts +2 -0
- package/dist/service/index.js +7 -0
- package/dist/service/index.js.map +1 -0
- package/dist/streaming/clients.d.ts +48 -0
- package/dist/streaming/clients.js +114 -0
- package/dist/streaming/clients.js.map +1 -0
- package/dist/streaming/fmp4Segmenter.d.ts +61 -0
- package/dist/streaming/fmp4Segmenter.js +461 -0
- package/dist/streaming/fmp4Segmenter.js.map +1 -0
- package/dist/streaming/hls.d.ts +120 -0
- package/dist/streaming/hls.js +722 -0
- package/dist/streaming/hls.js.map +1 -0
- package/dist/streaming/hlsSegments.d.ts +54 -0
- package/dist/streaming/hlsSegments.js +162 -0
- package/dist/streaming/hlsSegments.js.map +1 -0
- package/dist/streaming/lifecycle.d.ts +33 -0
- package/dist/streaming/lifecycle.js +185 -0
- package/dist/streaming/lifecycle.js.map +1 -0
- package/dist/streaming/monitor.d.ts +74 -0
- package/dist/streaming/monitor.js +1310 -0
- package/dist/streaming/monitor.js.map +1 -0
- package/dist/streaming/mp4Parser.d.ts +74 -0
- package/dist/streaming/mp4Parser.js +566 -0
- package/dist/streaming/mp4Parser.js.map +1 -0
- package/dist/streaming/mpegts.d.ts +14 -0
- package/dist/streaming/mpegts.js +248 -0
- package/dist/streaming/mpegts.js.map +1 -0
- package/dist/streaming/registry.d.ts +119 -0
- package/dist/streaming/registry.js +127 -0
- package/dist/streaming/registry.js.map +1 -0
- package/dist/streaming/setup.d.ts +135 -0
- package/dist/streaming/setup.js +670 -0
- package/dist/streaming/setup.js.map +1 -0
- package/dist/streaming/showInfo.d.ts +30 -0
- package/dist/streaming/showInfo.js +362 -0
- package/dist/streaming/showInfo.js.map +1 -0
- package/dist/streaming/statusEmitter.d.ts +125 -0
- package/dist/streaming/statusEmitter.js +139 -0
- package/dist/streaming/statusEmitter.js.map +1 -0
- package/dist/types/index.d.ts +403 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/debugFilter.d.ts +38 -0
- package/dist/utils/debugFilter.js +157 -0
- package/dist/utils/debugFilter.js.map +1 -0
- package/dist/utils/delay.d.ts +6 -0
- package/dist/utils/delay.js +15 -0
- package/dist/utils/delay.js.map +1 -0
- package/dist/utils/errors.d.ts +15 -0
- package/dist/utils/errors.js +40 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/evaluate.d.ts +51 -0
- package/dist/utils/evaluate.js +124 -0
- package/dist/utils/evaluate.js.map +1 -0
- package/dist/utils/ffmpeg.d.ts +65 -0
- package/dist/utils/ffmpeg.js +317 -0
- package/dist/utils/ffmpeg.js.map +1 -0
- package/dist/utils/fileLogger.d.ts +25 -0
- package/dist/utils/fileLogger.js +248 -0
- package/dist/utils/fileLogger.js.map +1 -0
- package/dist/utils/format.d.ts +16 -0
- package/dist/utils/format.js +46 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/html.d.ts +6 -0
- package/dist/utils/html.js +24 -0
- package/dist/utils/html.js.map +1 -0
- package/dist/utils/index.d.ts +15 -0
- package/dist/utils/index.js +20 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/logEmitter.d.ts +17 -0
- package/dist/utils/logEmitter.js +30 -0
- package/dist/utils/logEmitter.js.map +1 -0
- package/dist/utils/logger.d.ts +82 -0
- package/dist/utils/logger.js +219 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/m3u.d.ts +32 -0
- package/dist/utils/m3u.js +148 -0
- package/dist/utils/m3u.js.map +1 -0
- package/dist/utils/morganStream.d.ts +7 -0
- package/dist/utils/morganStream.js +33 -0
- package/dist/utils/morganStream.js.map +1 -0
- package/dist/utils/platform.d.ts +64 -0
- package/dist/utils/platform.js +157 -0
- package/dist/utils/platform.js.map +1 -0
- package/dist/utils/retry.d.ts +15 -0
- package/dist/utils/retry.js +82 -0
- package/dist/utils/retry.js.map +1 -0
- package/dist/utils/streamContext.d.ts +28 -0
- package/dist/utils/streamContext.js +33 -0
- package/dist/utils/streamContext.js.map +1 -0
- package/dist/utils/version.d.ts +37 -0
- package/dist/utils/version.js +228 -0
- package/dist/utils/version.js.map +1 -0
- package/package.json +92 -0
- package/prismcast.png +0 -0
- package/prismcast.svg +74 -0
|
@@ -0,0 +1,1205 @@
|
|
|
1
|
+
import { LOG, evaluateWithAbort, formatError, startTimer } from "../utils/index.js";
|
|
2
|
+
import { getAllStreams, getStreamCount } from "../streaming/registry.js";
|
|
3
|
+
import { getEffectivePreset, getPresetViewport } from "../config/presets.js";
|
|
4
|
+
import { getExtensionPage, getStream, launch } from "puppeteer-stream";
|
|
5
|
+
import { resizeAndMinimizeWindow, unminimizeWindow } from "./cdp.js";
|
|
6
|
+
import { setBrowserChrome, setMaxSupportedViewport } from "./display.js";
|
|
7
|
+
import { CONFIG } from "../config/index.js";
|
|
8
|
+
import { clearChannelSelectionCaches } from "./channelSelection.js";
|
|
9
|
+
import { emitSystemStatusChanged } from "../streaming/statusEmitter.js";
|
|
10
|
+
import { execSync } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { launch as puppeteerLaunch } from "puppeteer-core";
|
|
15
|
+
import { terminateStream } from "../streaming/lifecycle.js";
|
|
16
|
+
const { promises: fsPromises } = fs;
|
|
17
|
+
/* Global variables maintain the application's runtime state across all operations. We minimize global state where possible, but some values must be shared across
|
|
18
|
+
* the application lifecycle:
|
|
19
|
+
*
|
|
20
|
+
* - currentBrowser: The shared browser instance. All streaming sessions use a single Chrome process to avoid the overhead of launching multiple browsers. This is
|
|
21
|
+
* created on first stream request (or during warmup) and persists until the application shuts down or the browser crashes.
|
|
22
|
+
*
|
|
23
|
+
* - dataDir: The filesystem location for persistent data (Chrome profile, extension files). This is always ~/.prismcast, which is created on startup if it doesn't
|
|
24
|
+
* exist.
|
|
25
|
+
*
|
|
26
|
+
* Stream tracking and ID generation have been moved to streaming/registry.ts for unified stream management across all output types (direct WebM, HLS, etc.).
|
|
27
|
+
*/
|
|
28
|
+
// 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
|
|
29
|
+
// has disconnected.
|
|
30
|
+
let currentBrowser = null;
|
|
31
|
+
// The Chrome version string (e.g., "Chrome/144.0.7559.110") captured when the browser launches. Cleared when the browser disconnects. Used by the
|
|
32
|
+
// health endpoint to report the active Chrome version.
|
|
33
|
+
let currentChromeVersion = null;
|
|
34
|
+
// Timestamp (Date.now()) when the current browser instance was launched. Used by the opportunistic restart check to determine browser age. Cleared when the
|
|
35
|
+
// browser disconnects.
|
|
36
|
+
let browserLaunchTime = null;
|
|
37
|
+
// Launch mutex. When a browser launch is in progress, this holds the pending promise so that concurrent callers piggyback on the same launch instead of
|
|
38
|
+
// starting a second Chrome process. Cleared in a finally block once the launch settles.
|
|
39
|
+
let browserLaunchPromise = null;
|
|
40
|
+
// The data directory stores Chrome's profile data and the streaming extension files. This is always ~/.prismcast, which provides a consistent location for
|
|
41
|
+
// user-specific data regardless of how PrismCast is run.
|
|
42
|
+
const dataDir = path.join(os.homedir(), ".prismcast");
|
|
43
|
+
// The stale page cleanup interval handle, stored so we can clear it during graceful shutdown. The interval periodically checks for browser pages that are not
|
|
44
|
+
// associated with active streams and closes them to prevent resource exhaustion.
|
|
45
|
+
let stalePageCleanupInterval = null;
|
|
46
|
+
/* Opportunistic browser restart state. Chrome accumulates memory pressure, GPU process issues, and general flakiness over multi-hour sessions with continuous
|
|
47
|
+
* media playback. We proactively restart Chrome after it has been running for BROWSER_MAX_AGE, waiting for a quiet period with zero active streams before
|
|
48
|
+
* executing the restart. After the restart, a fresh browser is launched immediately so it is ready for the next stream request.
|
|
49
|
+
*/
|
|
50
|
+
// Maximum browser uptime before considering a restart (6 hours).
|
|
51
|
+
const BROWSER_MAX_AGE = 6 * 60 * 60 * 1000;
|
|
52
|
+
// Duration of the quiet period (zero streams) required before executing the restart (5 minutes).
|
|
53
|
+
const BROWSER_RESTART_QUIET_PERIOD = 5 * 60 * 1000;
|
|
54
|
+
// How often to check whether the browser qualifies for a restart (30 seconds).
|
|
55
|
+
const BROWSER_RESTART_CHECK_INTERVAL = 30_000;
|
|
56
|
+
// Timer handle for the quiet period countdown. When set, the browser has exceeded BROWSER_MAX_AGE and we are waiting for BROWSER_RESTART_QUIET_PERIOD to
|
|
57
|
+
// elapse with zero active streams. Cancelled if a stream starts during the quiet period.
|
|
58
|
+
let restartQuietTimer = null;
|
|
59
|
+
// Interval handle for the periodic restart eligibility check.
|
|
60
|
+
let restartCheckInterval = null;
|
|
61
|
+
// Flag indicating that the browser is being closed intentionally via closeBrowser(). When true, the disconnect handler skips error logging and stream termination
|
|
62
|
+
// since these are handled by the shutdown code path. This prevents false "unexpected disconnect" errors during graceful shutdown.
|
|
63
|
+
let gracefulShutdownInProgress = false;
|
|
64
|
+
/**
|
|
65
|
+
* Returns true if graceful shutdown is in progress.
|
|
66
|
+
*/
|
|
67
|
+
export function isGracefulShutdown() {
|
|
68
|
+
return gracefulShutdownInProgress;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Sets the graceful shutdown flag. Call this at the start of shutdown, before terminating streams, so that page close errors are suppressed.
|
|
72
|
+
*/
|
|
73
|
+
export function setGracefulShutdown(value) {
|
|
74
|
+
gracefulShutdownInProgress = value;
|
|
75
|
+
}
|
|
76
|
+
/* We track pages that PrismCast creates to distinguish them from pages that might be opened by other means (manually by the user, by site popups, etc.). Only pages we
|
|
77
|
+
* create should be subject to stale page cleanup. This prevents the cleanup from interfering with pages the user opened for debugging or pages created by
|
|
78
|
+
* streaming sites for authentication flows.
|
|
79
|
+
*
|
|
80
|
+
* We use a WeakMap to associate Page objects with unique string IDs. The WeakMap allows garbage collection of Page objects when they're no longer referenced
|
|
81
|
+
* elsewhere, while the ID strings provide stable identifiers for comparison and staleness tracking.
|
|
82
|
+
*/
|
|
83
|
+
// Counter for generating unique page IDs. Each managed page gets a unique ID when registered.
|
|
84
|
+
let managedPageIdCounter = 0;
|
|
85
|
+
// WeakMap from Page objects to their assigned unique IDs. Using a WeakMap allows the Page to be garbage collected when no longer referenced.
|
|
86
|
+
const pageToId = new WeakMap();
|
|
87
|
+
// Set of IDs for pages created by PrismCast. Pages are registered immediately after creation and unregistered during cleanup. Only pages with IDs in this set are
|
|
88
|
+
// candidates for stale page cleanup.
|
|
89
|
+
const managedPageIds = new Set();
|
|
90
|
+
// 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
|
|
91
|
+
// the configured grace period before being closed. This prevents race conditions where pages are briefly untracked during initialization or cleanup transitions.
|
|
92
|
+
const potentiallyStalePages = new Map();
|
|
93
|
+
/* Login mode allows users to authenticate with TV providers directly from the PrismCast web UI. When login mode is active:
|
|
94
|
+
*
|
|
95
|
+
* - A dedicated login tab is open in the browser showing the channel's URL
|
|
96
|
+
* - The browser window is un-minimized so the user can interact with it
|
|
97
|
+
* - New stream requests are blocked to prevent interference with the login process
|
|
98
|
+
* - A 15-minute timeout automatically ends login mode if the user forgets
|
|
99
|
+
*
|
|
100
|
+
* 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.
|
|
101
|
+
*/
|
|
102
|
+
// Whether login mode is currently active.
|
|
103
|
+
let loginModeActive = false;
|
|
104
|
+
// The browser page (tab) used for login. Set when login starts, cleared when login ends.
|
|
105
|
+
let loginPage = null;
|
|
106
|
+
// The URL being used for login. Stored for status reporting.
|
|
107
|
+
let loginUrl = null;
|
|
108
|
+
// Timestamp when login mode started. Used for status reporting and timeout calculation.
|
|
109
|
+
let loginStartTime = null;
|
|
110
|
+
// Timeout handle for auto-ending login mode after 15 minutes.
|
|
111
|
+
let loginTimeoutHandle = null;
|
|
112
|
+
// Login timeout duration (15 minutes).
|
|
113
|
+
const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
|
|
114
|
+
/**
|
|
115
|
+
* Computes the current system status and emits it to SSE subscribers. Called when browser state changes significantly or when streams are added/removed.
|
|
116
|
+
*/
|
|
117
|
+
export async function emitCurrentSystemStatus() {
|
|
118
|
+
let pageCount = 0;
|
|
119
|
+
try {
|
|
120
|
+
if (currentBrowser?.connected) {
|
|
121
|
+
const pages = await currentBrowser.pages();
|
|
122
|
+
pageCount = pages.length;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (_error) {
|
|
126
|
+
// Ignore errors getting page count.
|
|
127
|
+
}
|
|
128
|
+
const memUsage = process.memoryUsage();
|
|
129
|
+
const status = {
|
|
130
|
+
browser: {
|
|
131
|
+
connected: !!currentBrowser && currentBrowser.connected,
|
|
132
|
+
pageCount
|
|
133
|
+
},
|
|
134
|
+
memory: {
|
|
135
|
+
heapUsed: memUsage.heapUsed,
|
|
136
|
+
rss: memUsage.rss
|
|
137
|
+
},
|
|
138
|
+
streams: {
|
|
139
|
+
active: getStreamCount(),
|
|
140
|
+
limit: CONFIG.streaming.maxConcurrentStreams
|
|
141
|
+
},
|
|
142
|
+
uptime: process.uptime()
|
|
143
|
+
};
|
|
144
|
+
emitSystemStatusChanged(status);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Registers a page as managed by PrismCast. This should be called immediately after creating a page via browser.newPage(). Registered pages are tracked for stale
|
|
148
|
+
* page cleanup, while unregistered pages (manually opened, site popups, etc.) are left alone.
|
|
149
|
+
*
|
|
150
|
+
* Each registered page receives a unique ID that persists for the page's lifetime. This ID is used for comparison and staleness tracking, avoiding potential
|
|
151
|
+
* issues with Page object reference identity.
|
|
152
|
+
* @param page - The Puppeteer Page to register.
|
|
153
|
+
*/
|
|
154
|
+
export function registerManagedPage(page) {
|
|
155
|
+
// Generate a unique ID for this page.
|
|
156
|
+
const pageId = ["page-", String(++managedPageIdCounter)].join("");
|
|
157
|
+
// Associate the Page object with its ID.
|
|
158
|
+
pageToId.set(page, pageId);
|
|
159
|
+
// Track the ID as managed.
|
|
160
|
+
managedPageIds.add(pageId);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Unregisters a page from PrismCast's management. This should be called when a page is being closed intentionally (during stream cleanup). Unregistering prevents the
|
|
164
|
+
* stale page cleanup from racing with intentional page closure.
|
|
165
|
+
* @param page - The Puppeteer Page to unregister.
|
|
166
|
+
*/
|
|
167
|
+
export function unregisterManagedPage(page) {
|
|
168
|
+
const pageId = pageToId.get(page);
|
|
169
|
+
if (pageId) {
|
|
170
|
+
managedPageIds.delete(pageId);
|
|
171
|
+
// Also remove from potentially stale tracking since we're intentionally closing it.
|
|
172
|
+
potentiallyStalePages.delete(pageId);
|
|
173
|
+
// Note: We don't delete from pageToId because WeakMap handles cleanup automatically when the Page is garbage collected.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Gets the managed page ID for a page, if it exists.
|
|
178
|
+
* @param page - The Puppeteer Page to look up.
|
|
179
|
+
* @returns The page ID if the page is managed, undefined otherwise.
|
|
180
|
+
*/
|
|
181
|
+
function getManagedPageId(page) {
|
|
182
|
+
return pageToId.get(page);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Gets the current data directory path. This is where Chrome profile data and extension files are stored.
|
|
186
|
+
* @returns The data directory path.
|
|
187
|
+
*/
|
|
188
|
+
export function getDataDir() {
|
|
189
|
+
return dataDir;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Ensures the data directory exists, creating it if necessary. This should be called during application startup before any operations that depend on the data
|
|
193
|
+
* directory (like browser launch or extension preparation).
|
|
194
|
+
*
|
|
195
|
+
* The data directory (~/.prismcast) stores:
|
|
196
|
+
* - Chrome profile data (cookies, local storage, session state)
|
|
197
|
+
* - Extension files (when running as a packaged executable)
|
|
198
|
+
*/
|
|
199
|
+
export async function ensureDataDirectory() {
|
|
200
|
+
try {
|
|
201
|
+
await fsPromises.mkdir(dataDir, { recursive: true });
|
|
202
|
+
LOG.debug("browser", "Data directory ready: %s.", dataDir);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
LOG.error("Failed to create data directory %s: %s.", dataDir, formatError(error));
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/* These functions handle the Chrome browser lifecycle: startup, cleanup, and instance management. The browser is a shared resource used by all streaming sessions,
|
|
210
|
+
* so careful lifecycle management is essential for reliability. Key considerations:
|
|
211
|
+
*
|
|
212
|
+
* - Single browser instance: We use one Chrome process for all streams to minimize resource overhead. Each stream gets its own tab (page) within that browser.
|
|
213
|
+
*
|
|
214
|
+
* - Profile locking: Chrome locks its user data directory while running. If a previous instance crashed without releasing the lock, we must kill it before
|
|
215
|
+
* launching a new browser.
|
|
216
|
+
*
|
|
217
|
+
* - Crash recovery: The browser can crash or disconnect unexpectedly. When this happens, we clean up all active streams (they cannot continue without a browser)
|
|
218
|
+
* and reset state so the next stream request will launch a fresh browser.
|
|
219
|
+
*
|
|
220
|
+
* - Extension initialization: The puppeteer-stream extension needs time after browser launch to inject its recording APIs. We wait for this initialization before
|
|
221
|
+
* attempting to capture streams.
|
|
222
|
+
*/
|
|
223
|
+
/**
|
|
224
|
+
* Ensures a clean slate for browser launch by terminating any stale Chrome processes and removing orphaned profile lock files. Chrome locks its profile directory
|
|
225
|
+
* 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 pkill to
|
|
226
|
+
* find and terminate any Chrome processes whose command line contains our profile directory path, then polls pgrep to verify the processes have actually exited.
|
|
227
|
+
* After process cleanup, it removes stale lock files (SingletonLock, SingletonCookie, SingletonSocket) and DevToolsActivePort from the profile directory.
|
|
228
|
+
*
|
|
229
|
+
* The termination strategy escalates from SIGTERM to SIGKILL. SIGTERM is sent first, giving Chrome up to 5 seconds to flush its profile databases (LevelDB,
|
|
230
|
+
* 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
|
|
231
|
+
* process exit handler — Chrome may be running normally (e.g., after a capture probe timeout), and an immediate SIGKILL would corrupt its profile databases,
|
|
232
|
+
* poisoning the Docker volume for subsequent container restarts.
|
|
233
|
+
*
|
|
234
|
+
* The file cleanup is essential for Docker deployments. Container restarts destroy Chrome processes without giving them a chance to release profile locks, but the
|
|
235
|
+
* lock files persist in the mounted volume. Without removing them, Chrome cannot start in the new container, causing a crash loop.
|
|
236
|
+
*
|
|
237
|
+
* This is called at startup before launching the browser and after closeBrowser() during shutdown. It's safe to call even when no stale processes or files exist.
|
|
238
|
+
*/
|
|
239
|
+
export function killStaleChrome() {
|
|
240
|
+
// Build the profile directory path that would appear in Chrome's command-line arguments.
|
|
241
|
+
const profileDir = path.join(dataDir, CONFIG.paths.chromeProfileName);
|
|
242
|
+
const POLL_INTERVAL_MS = 200;
|
|
243
|
+
try {
|
|
244
|
+
// Send SIGTERM first to give Chrome a chance to flush its profile databases (LevelDB, extension state, session storage) before exiting. This is critical
|
|
245
|
+
// 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
|
|
246
|
+
// databases, poisoning the Docker volume for subsequent restarts.
|
|
247
|
+
execSync(["pkill -f \"", profileDir, "\""].join(""));
|
|
248
|
+
LOG.debug("browser", "Sent SIGTERM to Chrome instances using %s.", profileDir);
|
|
249
|
+
// Wait up to 5 seconds for Chrome to flush its databases and exit after SIGTERM. Containerized environments with software rendering and shared CPU may
|
|
250
|
+
// need the full window.
|
|
251
|
+
const TERM_WAIT_MS = 5000;
|
|
252
|
+
if (!waitForChromeExit(profileDir, TERM_WAIT_MS, POLL_INTERVAL_MS)) {
|
|
253
|
+
// SIGTERM didn't work. Escalate to SIGKILL. Orphaned Chrome processes (from a crashed parent or previous container) may not respond to SIGTERM.
|
|
254
|
+
LOG.debug("browser", "Chrome did not exit after SIGTERM. Escalating to SIGKILL.");
|
|
255
|
+
try {
|
|
256
|
+
execSync(["pkill -9 -f \"", profileDir, "\""].join(""));
|
|
257
|
+
}
|
|
258
|
+
catch (_error) {
|
|
259
|
+
// No matching processes — Chrome may have exited between the pgrep check and the pkill.
|
|
260
|
+
}
|
|
261
|
+
const KILL_WAIT_MS = 2000;
|
|
262
|
+
if (!waitForChromeExit(profileDir, KILL_WAIT_MS, POLL_INTERVAL_MS)) {
|
|
263
|
+
LOG.warn("Chrome processes did not exit after %sms of signal escalation. Proceeding anyway.", TERM_WAIT_MS + KILL_WAIT_MS);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (_error) {
|
|
268
|
+
// 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.
|
|
269
|
+
}
|
|
270
|
+
// Remove stale lock and port files left behind by an unclean Chrome exit.
|
|
271
|
+
cleanStaleProfileFiles(profileDir);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Polls pgrep until no Chrome processes matching the profile directory remain, or the timeout expires. pgrep returns exit code 0 when matching processes exist
|
|
275
|
+
* and non-zero when none remain.
|
|
276
|
+
* @param profileDir - The Chrome profile directory path to match against process command lines.
|
|
277
|
+
* @param timeoutMs - Maximum time to wait in milliseconds.
|
|
278
|
+
* @param pollIntervalMs - Time between pgrep checks in milliseconds.
|
|
279
|
+
* @returns True if all matching processes exited within the timeout, false otherwise.
|
|
280
|
+
*/
|
|
281
|
+
function waitForChromeExit(profileDir, timeoutMs, pollIntervalMs) {
|
|
282
|
+
const deadline = Date.now() + timeoutMs;
|
|
283
|
+
while (Date.now() < deadline) {
|
|
284
|
+
try {
|
|
285
|
+
execSync(["pgrep -f \"", profileDir, "\""].join(""), { stdio: "ignore" });
|
|
286
|
+
// Processes still exist. Wait and check again.
|
|
287
|
+
execSync(["sleep ", String(pollIntervalMs / 1000)].join(""));
|
|
288
|
+
}
|
|
289
|
+
catch (_error) {
|
|
290
|
+
// pgrep returned non-zero — no matching processes remain.
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* 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
|
|
298
|
+
* (container kill, SIGKILL, crash) leaves them behind. Stale lock files prevent Chrome from acquiring the profile, and a stale DevToolsActivePort can confuse the
|
|
299
|
+
* Puppeteer connection.
|
|
300
|
+
* @param profileDir - The Chrome user data directory path.
|
|
301
|
+
*/
|
|
302
|
+
function cleanStaleProfileFiles(profileDir) {
|
|
303
|
+
// Chrome's profile lock mechanism uses three symlinks: SingletonLock (hostname-PID pair), SingletonCookie (numeric verification token), and SingletonSocket
|
|
304
|
+
// (path to the IPC socket). All three must be removed for Chrome to acquire a fresh lock. DevToolsActivePort contains the debugging port from the previous
|
|
305
|
+
// session and is irrelevant when launching a new browser instance.
|
|
306
|
+
const staleFiles = ["DevToolsActivePort", "SingletonCookie", "SingletonLock", "SingletonSocket"];
|
|
307
|
+
for (const file of staleFiles) {
|
|
308
|
+
const filePath = path.join(profileDir, file);
|
|
309
|
+
try {
|
|
310
|
+
fs.unlinkSync(filePath);
|
|
311
|
+
LOG.debug("browser", "Removed stale profile file: %s.", file);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
// ENOENT means the file doesn't exist, which is the expected case after a clean shutdown. Any other error (permissions, filesystem issues) is worth
|
|
315
|
+
// logging as a warning since it could prevent Chrome from starting.
|
|
316
|
+
if (error.code !== "ENOENT") {
|
|
317
|
+
LOG.warn("Failed to remove stale profile file %s: %s.", file, formatError(error));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Locates the Google Chrome executable on the system. The CHROME_BIN environment variable takes precedence, allowing operators to specify a non-standard
|
|
324
|
+
* installation. Otherwise, we search common installation paths across macOS, Linux, and Windows.
|
|
325
|
+
*
|
|
326
|
+
* @returns Path to the Chrome executable.
|
|
327
|
+
* @throws If no Chrome installation is found.
|
|
328
|
+
*/
|
|
329
|
+
export function getExecutablePath() {
|
|
330
|
+
// Environment variable override takes precedence. This is useful for containerized deployments or non-standard installations.
|
|
331
|
+
if (CONFIG.browser.executablePath) {
|
|
332
|
+
return CONFIG.browser.executablePath;
|
|
333
|
+
}
|
|
334
|
+
// Check standard Google Chrome installation paths across platforms.
|
|
335
|
+
const paths = [
|
|
336
|
+
// macOS. Applications are typically in /Applications with .app bundles containing the actual executable.
|
|
337
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
338
|
+
// Linux. Chrome packages install to /usr/bin with naming conventions that vary by distribution.
|
|
339
|
+
"/usr/bin/google-chrome",
|
|
340
|
+
"/usr/bin/google-chrome-stable",
|
|
341
|
+
// Windows. Both 64-bit (Program Files) and 32-bit (Program Files (x86)) installations are checked.
|
|
342
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
343
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
344
|
+
];
|
|
345
|
+
// Return the first path that exists on the filesystem.
|
|
346
|
+
const found = paths.find(fs.existsSync);
|
|
347
|
+
if (found) {
|
|
348
|
+
return found;
|
|
349
|
+
}
|
|
350
|
+
throw new Error("No Chrome installation found. Set CHROME_BIN environment variable.");
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Assembles the configuration options for launching Chrome with Puppeteer. These options are critical for reliable streaming:
|
|
354
|
+
*
|
|
355
|
+
* - Chrome flags configure the browser for unattended video playback without user interaction
|
|
356
|
+
* - Ignored default args prevent Puppeteer from disabling features we need (extensions, audio, component updates)
|
|
357
|
+
* - A persistent user data directory retains cookies and login state across restarts
|
|
358
|
+
* - Pipe mode provides a faster, more reliable connection than WebSocket
|
|
359
|
+
* @returns Puppeteer launch options.
|
|
360
|
+
*/
|
|
361
|
+
export function buildLaunchOptions() {
|
|
362
|
+
return {
|
|
363
|
+
/* Chrome command-line arguments. Each flag serves a specific purpose for reliable streaming:
|
|
364
|
+
*
|
|
365
|
+
* --allow-running-insecure-content: Some streaming sites serve mixed HTTP/HTTPS content. Without this flag, the browser blocks HTTP resources on HTTPS
|
|
366
|
+
* pages, which can break video players that load some assets over HTTP.
|
|
367
|
+
*
|
|
368
|
+
* --autoplay-policy=no-user-gesture-required: Allows video and audio to play without requiring a user click first. Essential for automated streaming
|
|
369
|
+
* since we cannot simulate genuine user interaction for autoplay policy purposes.
|
|
370
|
+
*
|
|
371
|
+
* --disable-background-media-suspend: Prevents Chrome from pausing media when the tab is backgrounded or the window is minimized. Critical since we
|
|
372
|
+
* minimize the browser to reduce GPU usage but still need media to play.
|
|
373
|
+
*
|
|
374
|
+
* --disable-background-networking: Reduces unnecessary network activity from background Chrome services (Safe Browsing updates, etc). This reduces
|
|
375
|
+
* resource usage and potential interference with stream capture.
|
|
376
|
+
*
|
|
377
|
+
* --disable-background-timer-throttling: Prevents Chrome from throttling JavaScript timers in background tabs. Video players often use timers for
|
|
378
|
+
* playback state management, and throttling can cause stuttering or stalls.
|
|
379
|
+
*
|
|
380
|
+
* --disable-backgrounding-occluded-windows: Prevents Chrome from reducing activity when the window is covered by other windows. Similar to the timer
|
|
381
|
+
* throttling issue, this ensures consistent playback even when the browser isn't visible.
|
|
382
|
+
*
|
|
383
|
+
* --disable-blink-features=AutomationControlled: Hides the navigator.webdriver property that indicates automated control. Some sites detect and block
|
|
384
|
+
* automated browsers; this flag helps avoid that detection.
|
|
385
|
+
*
|
|
386
|
+
* --disable-notifications: Prevents notification permission prompts and popups that could interfere with video capture or require user interaction.
|
|
387
|
+
*
|
|
388
|
+
* --hide-crash-restore-bubble: Suppresses the "Chrome didn't shut down correctly" dialog that appears after a crash. This prevents the dialog from
|
|
389
|
+
* blocking the viewport during capture.
|
|
390
|
+
*
|
|
391
|
+
* --hide-scrollbars: Removes scrollbars from the viewport to ensure the video fills the entire capture area without UI chrome.
|
|
392
|
+
*
|
|
393
|
+
* --no-first-run: Skips the first-run experience dialogs and setup wizard that would require user interaction.
|
|
394
|
+
*
|
|
395
|
+
* --window-size: Sets the initial window size to match our configured viewport dimensions. This is later adjusted via CDP to account for browser chrome.
|
|
396
|
+
*/
|
|
397
|
+
args: [
|
|
398
|
+
"--allow-running-insecure-content",
|
|
399
|
+
"--autoplay-policy=no-user-gesture-required",
|
|
400
|
+
"--disable-background-media-suspend",
|
|
401
|
+
"--disable-background-networking",
|
|
402
|
+
"--disable-background-timer-throttling",
|
|
403
|
+
"--disable-backgrounding-occluded-windows",
|
|
404
|
+
"--disable-blink-features=AutomationControlled",
|
|
405
|
+
"--disable-notifications",
|
|
406
|
+
"--hide-crash-restore-bubble",
|
|
407
|
+
"--hide-scrollbars",
|
|
408
|
+
"--no-first-run",
|
|
409
|
+
["--window-size=", String(getPresetViewport(CONFIG).width), ",", String(getPresetViewport(CONFIG).height)].join("")
|
|
410
|
+
],
|
|
411
|
+
// Disable Puppeteer's default viewport constraints. We manage viewport sizing ourselves via CDP to account for browser chrome (toolbars, borders) and
|
|
412
|
+
// ensure the content area matches our target dimensions exactly.
|
|
413
|
+
defaultViewport: null,
|
|
414
|
+
// Path to the Chrome executable, either from environment variable or autodetected.
|
|
415
|
+
executablePath: getExecutablePath(),
|
|
416
|
+
// Run Chrome in headed (visible) mode, not headless. The puppeteer-stream extension requires a visible browser window to capture the screen. We minimize
|
|
417
|
+
// the window after launch to reduce GPU usage while still allowing capture.
|
|
418
|
+
headless: false,
|
|
419
|
+
/* Prevent Puppeteer from adding certain default arguments that would interfere with streaming:
|
|
420
|
+
*
|
|
421
|
+
* --disable-component-extensions-with-background-pages: We need extension background pages for puppeteer-stream to function.
|
|
422
|
+
*
|
|
423
|
+
* --disable-component-update: We want component updates for codec support and security patches.
|
|
424
|
+
*
|
|
425
|
+
* --disable-default-apps: Default apps don't interfere, but we keep them for consistency with normal Chrome behavior.
|
|
426
|
+
*
|
|
427
|
+
* --disable-extensions: We absolutely need extensions enabled for puppeteer-stream to work. This is the most critical override.
|
|
428
|
+
*
|
|
429
|
+
* --enable-automation: This sets navigator.webdriver=true, which some sites use to detect and block automated browsers. We disable this detection by
|
|
430
|
+
* not setting this flag (and using --disable-blink-features=AutomationControlled above).
|
|
431
|
+
*
|
|
432
|
+
* --enable-blink-features=IdleDetection: Idle detection can interfere with background playback by triggering "user idle" events.
|
|
433
|
+
*
|
|
434
|
+
* --mute-audio: We need audio capture, so audio must not be muted. The puppeteer-stream extension captures both video and audio.
|
|
435
|
+
*/
|
|
436
|
+
ignoreDefaultArgs: [
|
|
437
|
+
"--disable-component-extensions-with-background-pages",
|
|
438
|
+
"--disable-component-update",
|
|
439
|
+
"--disable-default-apps",
|
|
440
|
+
"--disable-extensions",
|
|
441
|
+
"--enable-automation",
|
|
442
|
+
"--enable-blink-features=IdleDetection",
|
|
443
|
+
"--mute-audio"
|
|
444
|
+
],
|
|
445
|
+
// Use pipe mode for browser communication instead of WebSocket. Pipe mode is faster and more reliable, especially under load. It uses stdin/stdout for
|
|
446
|
+
// the DevTools Protocol connection rather than a network socket.
|
|
447
|
+
pipe: true,
|
|
448
|
+
// Persistent user data directory for Chrome profile. This directory stores cookies, local storage, and other session data. By persisting this across
|
|
449
|
+
// restarts, sites remember login state and don't require re-authentication.
|
|
450
|
+
userDataDir: path.join(dataDir, CONFIG.paths.chromeProfileName)
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Custom launch function that modifies Chrome arguments when running as a packaged executable. The packaged version cannot load extensions from node_modules
|
|
455
|
+
* (which is bundled inside the executable), so we point the extension paths to our extracted extension files in the data directory.
|
|
456
|
+
* @param opts - The launch options to modify.
|
|
457
|
+
* @returns The launched browser instance.
|
|
458
|
+
*/
|
|
459
|
+
async function launchWithCustomArgs(opts) {
|
|
460
|
+
// When running as a packaged executable (process.pkg is set by the pkg bundler), we need to replace the extension paths. The puppeteer-stream library adds
|
|
461
|
+
// --load-extension and --disable-extensions-except arguments pointing to node_modules, but these paths don't exist in the packaged executable. We replace
|
|
462
|
+
// them with paths to our extracted extension files.
|
|
463
|
+
if (process.pkg) {
|
|
464
|
+
const extensionPath = path.join(dataDir, CONFIG.paths.extensionDirName);
|
|
465
|
+
// Remove any existing extension arguments and add our own pointing to the extracted extension.
|
|
466
|
+
opts.args = (opts.args ?? [])
|
|
467
|
+
.filter((arg) => {
|
|
468
|
+
return !arg.startsWith("--load-extension=") && !arg.startsWith("--disable-extensions-except=");
|
|
469
|
+
})
|
|
470
|
+
.concat([
|
|
471
|
+
["--disable-extensions-except=", extensionPath].join(""),
|
|
472
|
+
["--load-extension=", extensionPath].join("")
|
|
473
|
+
]);
|
|
474
|
+
}
|
|
475
|
+
return puppeteerLaunch(opts);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Detects the maximum supported viewport dimensions based on the user's display. This function measures the available screen space and subtracts browser chrome to
|
|
479
|
+
* determine the largest viewport we can use for video capture.
|
|
480
|
+
*
|
|
481
|
+
* The detection uses a temporary page (or existing page if available) to evaluate screen dimensions via JavaScript. The result is cached in the display module for
|
|
482
|
+
* use by the preset system when determining effective viewport.
|
|
483
|
+
* @param browser - The browser instance to use for detection.
|
|
484
|
+
*/
|
|
485
|
+
async function detectDisplayDimensions(browser) {
|
|
486
|
+
let tempPage = null;
|
|
487
|
+
let usingTempPage = false;
|
|
488
|
+
try {
|
|
489
|
+
// Try to use an existing page first to avoid window activation issues on macOS.
|
|
490
|
+
const existingPages = await browser.pages();
|
|
491
|
+
let targetPage = existingPages.find((p) => !p.isClosed()) ?? null;
|
|
492
|
+
if (!targetPage) {
|
|
493
|
+
tempPage = await browser.newPage();
|
|
494
|
+
usingTempPage = true;
|
|
495
|
+
targetPage = tempPage;
|
|
496
|
+
}
|
|
497
|
+
// Measure display dimensions and browser chrome via JavaScript.
|
|
498
|
+
const dimensions = await evaluateWithAbort(targetPage, () => {
|
|
499
|
+
return {
|
|
500
|
+
// Available screen dimensions (excludes taskbar, dock, menu bar).
|
|
501
|
+
availHeight: screen.availHeight,
|
|
502
|
+
availWidth: screen.availWidth,
|
|
503
|
+
// Browser chrome dimensions (title bar, toolbar, borders).
|
|
504
|
+
chromeHeight: window.outerHeight - window.innerHeight,
|
|
505
|
+
chromeWidth: window.outerWidth - window.innerWidth
|
|
506
|
+
};
|
|
507
|
+
});
|
|
508
|
+
// Calculate maximum viewport: available screen space minus browser chrome.
|
|
509
|
+
const maxWidth = dimensions.availWidth - dimensions.chromeWidth;
|
|
510
|
+
const maxHeight = dimensions.availHeight - dimensions.chromeHeight;
|
|
511
|
+
// Cache the results for use by the preset system and window sizing.
|
|
512
|
+
setBrowserChrome(dimensions.chromeWidth, dimensions.chromeHeight);
|
|
513
|
+
setMaxSupportedViewport(maxWidth, maxHeight);
|
|
514
|
+
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);
|
|
515
|
+
// Check if the configured preset needs to be degraded and warn the user.
|
|
516
|
+
const presetResult = getEffectivePreset(CONFIG);
|
|
517
|
+
if (presetResult.degraded && presetResult.maxViewport) {
|
|
518
|
+
LOG.warn("Display supports maximum %s\u00d7%s. Configured %s preset will use %s instead.", presetResult.maxViewport.width, presetResult.maxViewport.height, presetResult.configuredPreset.id, presetResult.effectivePreset.id);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
LOG.warn("Display detection failed: %s. Preset degradation will not be available.", formatError(error));
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
// Clean up temporary page if we created one.
|
|
526
|
+
if (usingTempPage && tempPage) {
|
|
527
|
+
try {
|
|
528
|
+
await tempPage.close();
|
|
529
|
+
}
|
|
530
|
+
catch (_closeError) {
|
|
531
|
+
// Ignore close errors.
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Handles browser disconnection events by cleaning up all active streams and resetting browser state. This function is called when the browser crashes, is closed
|
|
538
|
+
* manually, or loses its connection for any other reason.
|
|
539
|
+
*
|
|
540
|
+
* When the browser disconnects, all pages (tabs) within it are immediately invalid and cannot be used. Any active streams using those pages will fail if they try
|
|
541
|
+
* to interact with them. We proactively clean up by:
|
|
542
|
+
* 1. Setting currentBrowser to null so the next stream request will launch a fresh browser
|
|
543
|
+
* 2. Stopping all health monitors (they would fail trying to check page state)
|
|
544
|
+
* 3. Removing all entries from activeStreams (the pages are gone)
|
|
545
|
+
*
|
|
546
|
+
* This ensures streams fail gracefully rather than hanging indefinitely trying to use closed pages.
|
|
547
|
+
*/
|
|
548
|
+
function handleBrowserDisconnect() {
|
|
549
|
+
// Clear the browser reference, launch timestamp, and cached version so getCurrentBrowser() will launch a new instance on the next call.
|
|
550
|
+
currentBrowser = null;
|
|
551
|
+
browserLaunchTime = null;
|
|
552
|
+
currentChromeVersion = null;
|
|
553
|
+
// Cancel any pending restart quiet timer since the browser is already gone.
|
|
554
|
+
if (restartQuietTimer) {
|
|
555
|
+
clearTimeout(restartQuietTimer);
|
|
556
|
+
restartQuietTimer = null;
|
|
557
|
+
}
|
|
558
|
+
// Clear all channel selection caches. Cached state (guide row positions, discovered page URLs) may be stale in a new browser session.
|
|
559
|
+
clearChannelSelectionCaches();
|
|
560
|
+
// Clear login state if login mode was active. We clear directly rather than calling endLoginMode() because the browser is already gone and we don't want to
|
|
561
|
+
// attempt any browser operations.
|
|
562
|
+
if (loginModeActive) {
|
|
563
|
+
if (loginTimeoutHandle) {
|
|
564
|
+
clearTimeout(loginTimeoutHandle);
|
|
565
|
+
loginTimeoutHandle = null;
|
|
566
|
+
}
|
|
567
|
+
loginModeActive = false;
|
|
568
|
+
loginPage = null;
|
|
569
|
+
loginUrl = null;
|
|
570
|
+
loginStartTime = null;
|
|
571
|
+
if (!gracefulShutdownInProgress) {
|
|
572
|
+
LOG.info("Login mode ended due to browser disconnect.");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// Only log the error for unexpected disconnects. During graceful shutdown, closeBrowser() set the flag and this disconnect is intentional.
|
|
576
|
+
if (!gracefulShutdownInProgress) {
|
|
577
|
+
LOG.error("Browser disconnected unexpectedly. All active streams will be terminated.");
|
|
578
|
+
}
|
|
579
|
+
// Clean up all active streams using the authoritative terminateStream function for consistent cleanup. This is kept even during graceful shutdown as a defensive
|
|
580
|
+
// measure - terminateStream() is idempotent, so if streams were already terminated by the caller, this harmlessly iterates an empty array.
|
|
581
|
+
const streams = getAllStreams();
|
|
582
|
+
for (const streamInfo of streams) {
|
|
583
|
+
terminateStream(streamInfo.id, streamInfo.info.storeKey, "browser disconnect");
|
|
584
|
+
}
|
|
585
|
+
// Emit system status after stream cleanup. Skip during graceful shutdown since no clients are listening and the process is exiting.
|
|
586
|
+
if (!gracefulShutdownInProgress) {
|
|
587
|
+
void emitCurrentSystemStatus();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Provides access to the shared browser instance, launching one if needed. The browser is a shared resource used by all streaming sessions. This function handles:
|
|
592
|
+
*
|
|
593
|
+
* - Returning the existing browser if it's still connected
|
|
594
|
+
* - Launching a new browser if none exists or the previous one disconnected
|
|
595
|
+
* - Serializing concurrent callers so only one launch occurs at a time
|
|
596
|
+
* - Waiting for the puppeteer-stream extension to initialize
|
|
597
|
+
* - Setting up disconnect handlers for crash recovery
|
|
598
|
+
* @returns The browser instance.
|
|
599
|
+
* @throws If the browser cannot be launched.
|
|
600
|
+
*/
|
|
601
|
+
export async function getCurrentBrowser() {
|
|
602
|
+
// Fast path: if we have a browser and it's still connected, return it immediately. The connected property verifies the DevTools Protocol connection is
|
|
603
|
+
// still alive.
|
|
604
|
+
if (currentBrowser?.connected) {
|
|
605
|
+
return currentBrowser;
|
|
606
|
+
}
|
|
607
|
+
// If a launch is already in progress (e.g., from the restart path or a concurrent stream request), piggyback on that promise instead of starting a second
|
|
608
|
+
// Chrome process. Two concurrent launches with the same profile directory would contend on Chrome's profile lock.
|
|
609
|
+
if (browserLaunchPromise) {
|
|
610
|
+
return browserLaunchPromise;
|
|
611
|
+
}
|
|
612
|
+
// We need to launch a new browser. Store the promise so concurrent callers can piggyback on this launch.
|
|
613
|
+
browserLaunchPromise = launchBrowser();
|
|
614
|
+
try {
|
|
615
|
+
return await browserLaunchPromise;
|
|
616
|
+
}
|
|
617
|
+
finally {
|
|
618
|
+
browserLaunchPromise = null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Launches a new browser instance and performs post-launch initialization (extension readiness, display detection, version capture). This is the inner launch
|
|
623
|
+
* function called by getCurrentBrowser() and serialized by the browserLaunchPromise mutex.
|
|
624
|
+
* @returns The browser instance.
|
|
625
|
+
* @throws If the browser cannot be launched.
|
|
626
|
+
*/
|
|
627
|
+
async function launchBrowser() {
|
|
628
|
+
const browserElapsed = startTimer();
|
|
629
|
+
// This happens on first stream request, after a browser crash, during server warmup, or during an opportunistic restart.
|
|
630
|
+
try {
|
|
631
|
+
const options = buildLaunchOptions();
|
|
632
|
+
// The launch function from puppeteer-stream wraps standard Puppeteer launch to inject the streaming extension. We pass our custom launch function that
|
|
633
|
+
// handles packaged executable extension paths.
|
|
634
|
+
currentBrowser = await launch({ launch: launchWithCustomArgs }, options);
|
|
635
|
+
// Register a handler for browser disconnection. This ensures we clean up properly if the browser crashes or is closed unexpectedly.
|
|
636
|
+
currentBrowser.on("disconnected", handleBrowserDisconnect);
|
|
637
|
+
LOG.debug("timing:browser", "Chrome process spawned. (+%sms)", browserElapsed());
|
|
638
|
+
// Poll for the puppeteer-stream extension to finish initializing. The extension injects a START_RECORDING function into its options page context. We poll
|
|
639
|
+
// for this function's existence rather than using a fixed delay, so the browser is ready as soon as the extension loads — typically 200-500ms rather than the
|
|
640
|
+
// full configured timeout. Uses getExtensionPage() from puppeteer-stream to locate the extension's options page.
|
|
641
|
+
try {
|
|
642
|
+
const extensionPage = await getExtensionPage(currentBrowser);
|
|
643
|
+
await extensionPage.waitForFunction("typeof START_RECORDING === 'function'", { timeout: CONFIG.browser.initTimeout });
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
// If the extension page isn't found or START_RECORDING doesn't appear within the timeout, log a warning and proceed. The per-stream
|
|
647
|
+
// assertExtensionLoaded() in puppeteer-stream will retry before each capture attempt, so this isn't fatal.
|
|
648
|
+
LOG.warn("Extension did not initialize within %d ms. Streams may need additional time to start.", CONFIG.browser.initTimeout);
|
|
649
|
+
}
|
|
650
|
+
LOG.debug("timing:browser", "Extension initialized. (+%sms)", browserElapsed());
|
|
651
|
+
// Detect display dimensions to determine maximum supported viewport. This must happen before we start streaming so the preset system can degrade to a
|
|
652
|
+
// smaller preset if needed.
|
|
653
|
+
await detectDisplayDimensions(currentBrowser);
|
|
654
|
+
LOG.debug("timing:browser", "Display detection complete. (+%sms)", browserElapsed());
|
|
655
|
+
// Log the Chrome version for diagnostic reference. This helps correlate browser behavior changes (tab unresponsiveness, memory pressure, capture issues)
|
|
656
|
+
// with specific Chrome releases.
|
|
657
|
+
const chromeVersion = await currentBrowser.version();
|
|
658
|
+
browserLaunchTime = Date.now();
|
|
659
|
+
currentChromeVersion = chromeVersion;
|
|
660
|
+
LOG.info("Chrome ready: %s.", chromeVersion);
|
|
661
|
+
LOG.debug("timing:browser", "Browser ready. Total: %sms.", browserElapsed());
|
|
662
|
+
// Emit system status update for SSE subscribers.
|
|
663
|
+
await emitCurrentSystemStatus();
|
|
664
|
+
}
|
|
665
|
+
catch (error) {
|
|
666
|
+
LOG.error("Failed to launch browser: %s.", formatError(error));
|
|
667
|
+
// Clear the browser reference, launch timestamp, and cached version on failure so the next call will attempt to launch again.
|
|
668
|
+
currentBrowser = null;
|
|
669
|
+
browserLaunchTime = null;
|
|
670
|
+
currentChromeVersion = null;
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
return currentBrowser;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Returns the Chrome version string captured when the browser launched, or null if the browser is not connected.
|
|
677
|
+
* @returns The Chrome version string (e.g., "Chrome/144.0.7559.110") or null.
|
|
678
|
+
*/
|
|
679
|
+
export function getChromeVersion() {
|
|
680
|
+
return currentChromeVersion;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Checks if the browser is currently connected and usable. This is a synchronous check that can be used before attempting browser operations.
|
|
684
|
+
* @returns True if the browser is connected and ready for use, false otherwise.
|
|
685
|
+
*/
|
|
686
|
+
export function isBrowserConnected() {
|
|
687
|
+
return !!currentBrowser && currentBrowser.connected;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Resizes the browser window to the effective viewport and minimizes it. This function combines viewport sizing with minimization to ensure the window is
|
|
691
|
+
* properly sized before being minimized. The resize uses the effective viewport from getEffectiveViewport(), which accounts for display size constraints and
|
|
692
|
+
* preset degradation.
|
|
693
|
+
*
|
|
694
|
+
* To avoid issues with creating temporary pages (which can cause the window to restore on macOS), we prefer using an existing page if one is available. Only if
|
|
695
|
+
* no pages exist do we create a temporary page.
|
|
696
|
+
*/
|
|
697
|
+
export async function minimizeBrowserWindow() {
|
|
698
|
+
// Guard against calling this when no browser is running.
|
|
699
|
+
if (!currentBrowser?.connected) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
let tempPage = null;
|
|
703
|
+
let usingTempPage = false;
|
|
704
|
+
try {
|
|
705
|
+
// Try to use an existing page first. Creating a new page can cause the window to restore/activate on macOS, which defeats the purpose of minimizing.
|
|
706
|
+
const existingPages = await currentBrowser.pages();
|
|
707
|
+
let targetPage = existingPages.find((p) => !p.isClosed()) ?? null;
|
|
708
|
+
// If no existing pages, we must create a temporary one. This is less ideal but necessary to get a CDP session target.
|
|
709
|
+
if (!targetPage) {
|
|
710
|
+
tempPage = await currentBrowser.newPage();
|
|
711
|
+
usingTempPage = true;
|
|
712
|
+
// Register the temp page so stale cleanup knows it's ours.
|
|
713
|
+
registerManagedPage(tempPage);
|
|
714
|
+
targetPage = tempPage;
|
|
715
|
+
}
|
|
716
|
+
// Delegate to resizeAndMinimizeWindow for the actual CDP operations. This ensures consistent resize+minimize behavior and maintains a single source of
|
|
717
|
+
// truth for the viewport sizing logic.
|
|
718
|
+
await resizeAndMinimizeWindow(targetPage, true);
|
|
719
|
+
// Clean up the temporary page if we created one.
|
|
720
|
+
if (usingTempPage && tempPage) {
|
|
721
|
+
unregisterManagedPage(tempPage);
|
|
722
|
+
await tempPage.close();
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
// If we created a temp page, make sure to unregister it even on error.
|
|
727
|
+
if (usingTempPage && tempPage) {
|
|
728
|
+
unregisterManagedPage(tempPage);
|
|
729
|
+
try {
|
|
730
|
+
await tempPage.close();
|
|
731
|
+
}
|
|
732
|
+
catch (_closeError) {
|
|
733
|
+
// Ignore close errors during error handling.
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// Resizing/minimizing is not critical - log a warning but don't fail the operation.
|
|
737
|
+
LOG.debug("browser", "Could not resize and minimize browser window: %s.", formatError(error));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Gets all open browser pages (tabs). This is used by the health check endpoint to report page count and by stale page cleanup to find orphaned pages.
|
|
742
|
+
* @returns Array of pages, or empty array if the browser is not connected.
|
|
743
|
+
*/
|
|
744
|
+
export async function getBrowserPages() {
|
|
745
|
+
// Guard against calling this when no browser is running.
|
|
746
|
+
if (!currentBrowser?.connected) {
|
|
747
|
+
return [];
|
|
748
|
+
}
|
|
749
|
+
try {
|
|
750
|
+
return await currentBrowser.pages();
|
|
751
|
+
}
|
|
752
|
+
catch (_error) {
|
|
753
|
+
// If getting pages fails (browser disconnecting, etc.), return empty array rather than throwing.
|
|
754
|
+
return [];
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* 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
|
|
759
|
+
* cleared and any subsequent stream requests will launch a fresh browser.
|
|
760
|
+
*
|
|
761
|
+
* The function uses a two-stage approach to ensure Chrome actually exits:
|
|
762
|
+
* 1. Try browser.close() with a 5-second timeout (DevTools Protocol graceful close)
|
|
763
|
+
* 2. Run killStaleChrome() to catch anything Stage 1 missed, using SIGTERM→SIGKILL escalation to give Chrome a chance to flush its profile databases
|
|
764
|
+
*/
|
|
765
|
+
export async function closeBrowser() {
|
|
766
|
+
// 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
|
|
767
|
+
// calls to closeBrowser().
|
|
768
|
+
setGracefulShutdown(true);
|
|
769
|
+
const browserRef = currentBrowser;
|
|
770
|
+
// Clear the reference, launch timestamp, and cached version early to prevent any new operations from using it.
|
|
771
|
+
currentBrowser = null;
|
|
772
|
+
browserLaunchTime = null;
|
|
773
|
+
currentChromeVersion = null;
|
|
774
|
+
if (!browserRef) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
// Stage 1: Try graceful close with a timeout. We use Promise.race to avoid hanging indefinitely if Chrome is unresponsive.
|
|
778
|
+
if (browserRef.connected) {
|
|
779
|
+
try {
|
|
780
|
+
await Promise.race([
|
|
781
|
+
browserRef.close(),
|
|
782
|
+
new Promise((_, reject) => setTimeout(() => { reject(new Error("Browser close timed out")); }, 5000))
|
|
783
|
+
]);
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
const message = formatError(error);
|
|
787
|
+
if (message.includes("timed out")) {
|
|
788
|
+
LOG.warn("Browser did not close within 5 seconds. Forcing termination.");
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
LOG.debug("browser", "Browser close error: %s.", message);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// Stage 2: Catch anything Stage 1 missed. If Chrome didn't respond to the DevTools close command (broken WebSocket, hung process), killStaleChrome()
|
|
796
|
+
// sends SIGTERM first to give Chrome a chance to flush its profile databases, then escalates to SIGKILL if needed.
|
|
797
|
+
killStaleChrome();
|
|
798
|
+
}
|
|
799
|
+
/* These functions manage the login mode workflow, allowing users to authenticate with TV providers through the browser. The workflow is:
|
|
800
|
+
*
|
|
801
|
+
* 1. User clicks "Login" on a channel in the web UI
|
|
802
|
+
* 2. startLoginMode() opens a new tab with the channel's URL and un-minimizes the browser
|
|
803
|
+
* 3. User completes authentication in the visible browser window
|
|
804
|
+
* 4. User clicks "Done" in the web UI, or closes the tab, or the 15-minute timeout fires
|
|
805
|
+
* 5. endLoginMode() closes the login tab (if still open) and re-minimizes the browser
|
|
806
|
+
*
|
|
807
|
+
* During login mode, new stream requests are blocked to prevent the browser from navigating away or creating conflicting tabs.
|
|
808
|
+
*/
|
|
809
|
+
/**
|
|
810
|
+
* 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
|
|
811
|
+
* provider in the visible browser.
|
|
812
|
+
*
|
|
813
|
+
* Login mode blocks new stream requests until it ends (via endLoginMode, tab close detection, or timeout).
|
|
814
|
+
* @param url - The URL to navigate to for authentication.
|
|
815
|
+
* @returns Object indicating success or failure with optional error message.
|
|
816
|
+
*/
|
|
817
|
+
export async function startLoginMode(url) {
|
|
818
|
+
// Check if login mode is already active.
|
|
819
|
+
if (loginModeActive) {
|
|
820
|
+
return { error: "Login is already in progress.", success: false };
|
|
821
|
+
}
|
|
822
|
+
// Ensure browser is available.
|
|
823
|
+
if (!currentBrowser?.connected) {
|
|
824
|
+
return { error: "Browser is not connected.", success: false };
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
// Create a new page for login. We intentionally do NOT register it as a managed page so stale page cleanup ignores it.
|
|
828
|
+
loginPage = await currentBrowser.newPage();
|
|
829
|
+
// Set up handler for tab close detection. If the user closes the tab manually, we should end login mode automatically.
|
|
830
|
+
loginPage.on("close", () => {
|
|
831
|
+
// Only auto-end if this is still the active login page.
|
|
832
|
+
if (loginModeActive && loginPage) {
|
|
833
|
+
LOG.info("Login tab was closed. Ending login mode.");
|
|
834
|
+
// Use void to handle the promise without awaiting (we're in an event handler).
|
|
835
|
+
void endLoginMode();
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
// Navigate to the login URL.
|
|
839
|
+
await loginPage.goto(url, { waitUntil: "domcontentloaded" });
|
|
840
|
+
// Un-minimize the browser window so the user can see and interact with it.
|
|
841
|
+
await unminimizeWindow(loginPage);
|
|
842
|
+
// Set login state.
|
|
843
|
+
loginModeActive = true;
|
|
844
|
+
loginUrl = url;
|
|
845
|
+
loginStartTime = Date.now();
|
|
846
|
+
// Set up the 15-minute timeout.
|
|
847
|
+
loginTimeoutHandle = setTimeout(() => {
|
|
848
|
+
LOG.warn("Login mode timed out after 15 minutes. Ending login mode.");
|
|
849
|
+
void endLoginMode();
|
|
850
|
+
}, LOGIN_TIMEOUT_MS);
|
|
851
|
+
LOG.info("Login mode started for %s.", url);
|
|
852
|
+
return { success: true };
|
|
853
|
+
}
|
|
854
|
+
catch (error) {
|
|
855
|
+
// Clean up on failure.
|
|
856
|
+
if (loginPage && !loginPage.isClosed()) {
|
|
857
|
+
try {
|
|
858
|
+
await loginPage.close();
|
|
859
|
+
}
|
|
860
|
+
catch (_closeError) {
|
|
861
|
+
// Ignore close errors.
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
loginPage = null;
|
|
865
|
+
return { error: formatError(error), success: false };
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* 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
|
|
870
|
+
* or when login mode is not active.
|
|
871
|
+
*
|
|
872
|
+
* Called by:
|
|
873
|
+
* - User clicking "Done" in the web UI (POST /auth/done)
|
|
874
|
+
* - Tab close detection (user closes the tab manually)
|
|
875
|
+
* - 15-minute timeout
|
|
876
|
+
* - Browser disconnect handler (cleanup)
|
|
877
|
+
*/
|
|
878
|
+
export async function endLoginMode() {
|
|
879
|
+
// Clear the timeout if it hasn't fired yet.
|
|
880
|
+
if (loginTimeoutHandle) {
|
|
881
|
+
clearTimeout(loginTimeoutHandle);
|
|
882
|
+
loginTimeoutHandle = null;
|
|
883
|
+
}
|
|
884
|
+
// Close the login page if it's still open.
|
|
885
|
+
if (loginPage && !loginPage.isClosed()) {
|
|
886
|
+
try {
|
|
887
|
+
await loginPage.close();
|
|
888
|
+
}
|
|
889
|
+
catch (_error) {
|
|
890
|
+
// Ignore close errors - the page may have already been closed.
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
// Clear login state.
|
|
894
|
+
const wasActive = loginModeActive;
|
|
895
|
+
loginModeActive = false;
|
|
896
|
+
loginPage = null;
|
|
897
|
+
loginUrl = null;
|
|
898
|
+
loginStartTime = null;
|
|
899
|
+
// Re-minimize the browser window.
|
|
900
|
+
if (wasActive && currentBrowser?.connected) {
|
|
901
|
+
await minimizeBrowserWindow();
|
|
902
|
+
}
|
|
903
|
+
if (wasActive) {
|
|
904
|
+
LOG.info("Login mode ended.");
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Returns whether login mode is currently active. Used by the stream handler to block new stream requests during login.
|
|
909
|
+
* @returns True if login mode is active, false otherwise.
|
|
910
|
+
*/
|
|
911
|
+
export function isLoginModeActive() {
|
|
912
|
+
return loginModeActive;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Returns the current login status including whether active, the URL being used, and when login started. Used by the /auth/status API endpoint.
|
|
916
|
+
* @returns Login status object.
|
|
917
|
+
*/
|
|
918
|
+
export function getLoginStatus() {
|
|
919
|
+
return {
|
|
920
|
+
active: loginModeActive,
|
|
921
|
+
startTime: loginStartTime,
|
|
922
|
+
url: loginUrl
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
/* 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
|
+
* edge cases in stream lifecycle management. Each orphaned page consumes memory and may continue running JavaScript, so we periodically clean them up.
|
|
927
|
+
*
|
|
928
|
+
* The cleanup has several safeguards to prevent closing pages that shouldn't be closed:
|
|
929
|
+
*
|
|
930
|
+
* 1. Only managed pages: We only consider pages that PrismCast created (tracked in managedPageIds). Pages opened manually by the user for debugging, or pages opened
|
|
931
|
+
* by streaming sites (OAuth popups, etc.) are left alone.
|
|
932
|
+
*
|
|
933
|
+
* 2. Target ID comparison: We use target IDs (strings) instead of Page object references for comparison. Puppeteer may return different wrapper objects for the
|
|
934
|
+
* same underlying page, making reference comparison unreliable.
|
|
935
|
+
*
|
|
936
|
+
* 3. Grace period: Pages must be observed as potentially stale for a configurable grace period before being closed. This handles race conditions where pages are
|
|
937
|
+
* briefly untracked during stream initialization or cleanup.
|
|
938
|
+
*
|
|
939
|
+
* 4. Minimum page preservation: We always keep at least one page open to prevent Chrome from exiting.
|
|
940
|
+
*/
|
|
941
|
+
/**
|
|
942
|
+
* Cleans up browser pages that are not associated with active streams. This function runs periodically to catch any pages that were not properly closed during
|
|
943
|
+
* stream termination.
|
|
944
|
+
*
|
|
945
|
+
* The cleanup uses a multi-stage filtering process:
|
|
946
|
+
* 1. Only consider pages we created (in managedPageIds)
|
|
947
|
+
* 2. Exclude pages associated with active streams
|
|
948
|
+
* 3. Apply a grace period before closing (to handle race conditions)
|
|
949
|
+
* 4. Preserve at least one page to keep the browser alive
|
|
950
|
+
*/
|
|
951
|
+
export async function cleanupStalePages() {
|
|
952
|
+
// Guard against calling this when no browser is running.
|
|
953
|
+
if (!currentBrowser?.connected) {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
try {
|
|
957
|
+
const pages = await currentBrowser.pages();
|
|
958
|
+
// If there's only one page or fewer, we must preserve it to keep the browser alive. Don't attempt cleanup.
|
|
959
|
+
if (pages.length <= 1) {
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
// Build a set of page IDs for pages currently in use by active streams.
|
|
963
|
+
const activePageIds = new Set();
|
|
964
|
+
for (const streamInfo of getAllStreams()) {
|
|
965
|
+
const pageId = getManagedPageId(streamInfo.page);
|
|
966
|
+
if (pageId) {
|
|
967
|
+
activePageIds.add(pageId);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const now = Date.now();
|
|
971
|
+
const gracePeriod = CONFIG.recovery.stalePageGracePeriod;
|
|
972
|
+
// Build a list of pages that are candidates for cleanup. A page is a candidate if:
|
|
973
|
+
// - It has a managed page ID (was created by PrismCast)
|
|
974
|
+
// - It is not associated with any active stream
|
|
975
|
+
// - It has been stale for longer than the grace period
|
|
976
|
+
const candidatePages = [];
|
|
977
|
+
// Track which managed page IDs we've seen in the current browser pages. Used for cleanup of stale tracking data.
|
|
978
|
+
const currentManagedIds = new Set();
|
|
979
|
+
for (const page of pages) {
|
|
980
|
+
const pageId = getManagedPageId(page);
|
|
981
|
+
// Skip pages we didn't create. This preserves manually opened pages and site popups.
|
|
982
|
+
if (!pageId) {
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
currentManagedIds.add(pageId);
|
|
986
|
+
// Skip pages associated with active streams.
|
|
987
|
+
if (activePageIds.has(pageId)) {
|
|
988
|
+
// If this page was previously marked as potentially stale, remove it from tracking since it's now active.
|
|
989
|
+
potentiallyStalePages.delete(pageId);
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
// This page is potentially stale. Track when we first observed it as such.
|
|
993
|
+
if (!potentiallyStalePages.has(pageId)) {
|
|
994
|
+
potentiallyStalePages.set(pageId, now);
|
|
995
|
+
// Don't close it yet - wait for the grace period.
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
// Check if the grace period has elapsed.
|
|
999
|
+
const firstSeenStale = potentiallyStalePages.get(pageId) ?? now;
|
|
1000
|
+
if ((now - firstSeenStale) < gracePeriod) {
|
|
1001
|
+
// Grace period hasn't elapsed yet. Leave this page alone for now.
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
// This page has been stale for longer than the grace period. It's a candidate for cleanup.
|
|
1005
|
+
candidatePages.push({ page, pageId });
|
|
1006
|
+
}
|
|
1007
|
+
// Clean up the potentiallyStalePages map by removing entries for pages that no longer exist. This handles cases where pages were closed by other means.
|
|
1008
|
+
for (const trackedId of potentiallyStalePages.keys()) {
|
|
1009
|
+
if (!currentManagedIds.has(trackedId)) {
|
|
1010
|
+
potentiallyStalePages.delete(trackedId);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
// Calculate how many pages we can close while still keeping at least one page open.
|
|
1014
|
+
const maxToClose = Math.max(0, pages.length - 1 - activePageIds.size);
|
|
1015
|
+
const pagesToClose = candidatePages.slice(0, maxToClose);
|
|
1016
|
+
let closedCount = 0;
|
|
1017
|
+
for (const { page, pageId } of pagesToClose) {
|
|
1018
|
+
try {
|
|
1019
|
+
// Unregister the page before closing to prevent any race with re-registration.
|
|
1020
|
+
managedPageIds.delete(pageId);
|
|
1021
|
+
potentiallyStalePages.delete(pageId);
|
|
1022
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1023
|
+
await page.close();
|
|
1024
|
+
closedCount++;
|
|
1025
|
+
}
|
|
1026
|
+
catch (_error) {
|
|
1027
|
+
// Page may have already been closed between our check and the close attempt. This is expected in race conditions.
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
// Log only if we actually closed something, to avoid log spam from idle cleanup runs.
|
|
1031
|
+
if (closedCount > 0) {
|
|
1032
|
+
LOG.debug("browser", "Cleaned up %s stale page(s).", closedCount);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
catch (error) {
|
|
1036
|
+
// Cleanup failure is not critical - log a warning and try again next interval.
|
|
1037
|
+
LOG.debug("browser", "Stale page cleanup failed: %s.", formatError(error));
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Starts the periodic stale page cleanup interval. This should be called once during server startup, after the browser is initialized. The interval runs
|
|
1042
|
+
* indefinitely until stopStalePageCleanup() is called (typically during graceful shutdown).
|
|
1043
|
+
*/
|
|
1044
|
+
export function startStalePageCleanup() {
|
|
1045
|
+
stalePageCleanupInterval = setInterval(() => { void cleanupStalePages(); }, CONFIG.recovery.stalePageCleanupInterval);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Stops the stale page cleanup interval. This should be called during graceful shutdown to prevent the cleanup from running after we've started shutting down
|
|
1049
|
+
* the browser and streams.
|
|
1050
|
+
*/
|
|
1051
|
+
export function stopStalePageCleanup() {
|
|
1052
|
+
if (stalePageCleanupInterval) {
|
|
1053
|
+
clearInterval(stalePageCleanupInterval);
|
|
1054
|
+
stalePageCleanupInterval = null;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
/* Opportunistic browser restart functions. The check runs on a 30-second interval and, when the browser exceeds BROWSER_MAX_AGE with zero active streams, starts
|
|
1058
|
+
* a quiet period timer. The quiet timer is cancelled if a stream starts, ensuring active viewers are never disrupted. When the timer expires, the browser is
|
|
1059
|
+
* closed and immediately re-launched.
|
|
1060
|
+
*/
|
|
1061
|
+
/**
|
|
1062
|
+
* Checks whether the browser qualifies for an opportunistic restart. Called periodically by the restart check interval. The check skips when any of these
|
|
1063
|
+
* conditions hold: graceful shutdown in progress, login mode active, browser not connected, browser age below threshold. If active streams exist, any pending
|
|
1064
|
+
* quiet timer is cancelled (streams started during the quiet period reset the countdown). Otherwise a quiet timer is started if one is not already running.
|
|
1065
|
+
*/
|
|
1066
|
+
function checkBrowserRestart() {
|
|
1067
|
+
// Skip if the server is shutting down, login mode is active, or the browser is not connected.
|
|
1068
|
+
if (gracefulShutdownInProgress || loginModeActive || !currentBrowser || !currentBrowser.connected || !browserLaunchTime) {
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
// Skip if the browser has not exceeded the maximum age.
|
|
1072
|
+
const age = Date.now() - browserLaunchTime;
|
|
1073
|
+
if (age < BROWSER_MAX_AGE) {
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
// If there are active streams, cancel any pending quiet timer and return. Streams that start during the quiet period reset the countdown.
|
|
1077
|
+
if (getStreamCount() > 0) {
|
|
1078
|
+
if (restartQuietTimer) {
|
|
1079
|
+
LOG.debug("browser", "Browser restart quiet period cancelled — streams are active.");
|
|
1080
|
+
clearTimeout(restartQuietTimer);
|
|
1081
|
+
restartQuietTimer = null;
|
|
1082
|
+
}
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
// No active streams and the browser is old enough. Start the quiet timer if one is not already running.
|
|
1086
|
+
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));
|
|
1088
|
+
restartQuietTimer = setTimeout(() => {
|
|
1089
|
+
void executeBrowserRestart();
|
|
1090
|
+
}, BROWSER_RESTART_QUIET_PERIOD);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Executes the opportunistic browser restart after the quiet period has elapsed. Performs a final guard check before proceeding, then closes the browser and
|
|
1095
|
+
* immediately re-launches a fresh instance.
|
|
1096
|
+
*/
|
|
1097
|
+
async function executeBrowserRestart() {
|
|
1098
|
+
// Clear the timer handle.
|
|
1099
|
+
restartQuietTimer = null;
|
|
1100
|
+
// 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
|
+
// mode was activated, or the browser disconnected on its own).
|
|
1102
|
+
if (gracefulShutdownInProgress || loginModeActive || (getStreamCount() > 0) || !currentBrowser || !currentBrowser.connected || !browserLaunchTime) {
|
|
1103
|
+
LOG.debug("browser", "Browser restart aborted — preconditions no longer met.");
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
const age = Date.now() - browserLaunchTime;
|
|
1107
|
+
const hours = Math.floor(age / 3600000);
|
|
1108
|
+
const minutes = Math.floor((age % 3600000) / 60000);
|
|
1109
|
+
LOG.info("Restarting browser for scheduled maintenance (uptime: %sh %sm).", hours, minutes);
|
|
1110
|
+
try {
|
|
1111
|
+
// closeBrowser() sets gracefulShutdownInProgress = true internally and performs multi-stage graceful close.
|
|
1112
|
+
await closeBrowser();
|
|
1113
|
+
// Reset the flag since the server is NOT shutting down — only the browser is restarting.
|
|
1114
|
+
setGracefulShutdown(false);
|
|
1115
|
+
// Launch a fresh browser instance so it is ready for the next stream request.
|
|
1116
|
+
await getCurrentBrowser();
|
|
1117
|
+
// Minimize the new window to reduce GPU usage and desktop clutter.
|
|
1118
|
+
await minimizeBrowserWindow();
|
|
1119
|
+
LOG.info("Browser restart complete. Fresh instance is ready.");
|
|
1120
|
+
}
|
|
1121
|
+
catch (error) {
|
|
1122
|
+
LOG.error("Browser restart failed: %s.", formatError(error));
|
|
1123
|
+
// Ensure the graceful shutdown flag is cleared even on failure so new stream requests can still launch a browser.
|
|
1124
|
+
setGracefulShutdown(false);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Starts the periodic browser restart eligibility check. This should be called once during server startup, after the browser is initialized. The interval runs
|
|
1129
|
+
* indefinitely until stopBrowserRestartChecking() is called (typically during graceful shutdown).
|
|
1130
|
+
*/
|
|
1131
|
+
export function startBrowserRestartChecking() {
|
|
1132
|
+
restartCheckInterval = setInterval(checkBrowserRestart, BROWSER_RESTART_CHECK_INTERVAL);
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Stops the browser restart checking interval and cancels any pending quiet timer. This should be called during graceful shutdown to prevent a restart from
|
|
1136
|
+
* racing with server shutdown.
|
|
1137
|
+
*/
|
|
1138
|
+
export function stopBrowserRestartChecking() {
|
|
1139
|
+
if (restartCheckInterval) {
|
|
1140
|
+
clearInterval(restartCheckInterval);
|
|
1141
|
+
restartCheckInterval = null;
|
|
1142
|
+
}
|
|
1143
|
+
if (restartQuietTimer) {
|
|
1144
|
+
clearTimeout(restartQuietTimer);
|
|
1145
|
+
restartQuietTimer = null;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
/* When running as a packaged executable (created by the `pkg` tool), the application is bundled into a single binary. Node modules like puppeteer-stream are
|
|
1149
|
+
* included in the bundle, but Chrome cannot load extensions from within the packaged binary - it needs actual files on the filesystem.
|
|
1150
|
+
*
|
|
1151
|
+
* To solve this, we extract the puppeteer-stream extension files to the application's data directory during startup. This happens only when process.pkg is
|
|
1152
|
+
* defined (indicating we're running as a packaged executable).
|
|
1153
|
+
*
|
|
1154
|
+
* The extracted files are:
|
|
1155
|
+
* - background.js: The extension's service worker that handles media capture
|
|
1156
|
+
* - manifest.json: The extension manifest declaring permissions and capabilities
|
|
1157
|
+
* - options.html/options.js: Extension options page (not used by our automation, but required by the manifest)
|
|
1158
|
+
*/
|
|
1159
|
+
/**
|
|
1160
|
+
* Extracts the Puppeteer Stream extension files when running as a packaged executable. This copies the extension files from within the packaged binary to the
|
|
1161
|
+
* filesystem where Chrome can load them.
|
|
1162
|
+
*
|
|
1163
|
+
* When running from source (not packaged), this function does nothing - puppeteer-stream can load the extension directly from node_modules.
|
|
1164
|
+
* @throws If extension extraction fails.
|
|
1165
|
+
*/
|
|
1166
|
+
export async function prepareExtension() {
|
|
1167
|
+
// Only needed when running as a packaged executable.
|
|
1168
|
+
if (!process.pkg) {
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
try {
|
|
1172
|
+
// The extension files are extracted to ~/.prismcast/extension (the data directory is ensured to exist before this function is called).
|
|
1173
|
+
const out = path.join(dataDir, CONFIG.paths.extensionDirName);
|
|
1174
|
+
// Create the extension directory if it doesn't exist.
|
|
1175
|
+
try {
|
|
1176
|
+
await fsPromises.mkdir(out, { recursive: true });
|
|
1177
|
+
}
|
|
1178
|
+
catch (error) {
|
|
1179
|
+
LOG.error("Failed to create extension directory: %s.", formatError(error));
|
|
1180
|
+
throw error;
|
|
1181
|
+
}
|
|
1182
|
+
// The extension files that need to be extracted. These are the files from puppeteer-stream's extension directory.
|
|
1183
|
+
const files = ["background.js", "manifest.json", "options.html", "options.js"];
|
|
1184
|
+
for (const file of files) {
|
|
1185
|
+
try {
|
|
1186
|
+
// Copy each file from the packaged location (relative to the executable) to the data directory. The source path assumes the executable is in the
|
|
1187
|
+
// same directory as node_modules (which is how pkg packages the application).
|
|
1188
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1189
|
+
await fsPromises.copyFile(path.join(path.dirname(process.execPath), "node_modules", "puppeteer-stream", "extension", file), path.join(out, file));
|
|
1190
|
+
}
|
|
1191
|
+
catch (error) {
|
|
1192
|
+
LOG.error("Failed to copy extension file %s: %s.", file, formatError(error));
|
|
1193
|
+
throw error;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
LOG.debug("browser", "Extension files prepared successfully.");
|
|
1197
|
+
}
|
|
1198
|
+
catch (error) {
|
|
1199
|
+
LOG.error("Extension preparation failed: %s.", formatError(error));
|
|
1200
|
+
throw error;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
// Re-export getStream from puppeteer-stream for use by the streaming module. This keeps all puppeteer-stream imports centralized in the browser module.
|
|
1204
|
+
export { getStream };
|
|
1205
|
+
//# sourceMappingURL=index.js.map
|