@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.
Files changed (234) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +347 -0
  3. package/bin/prismcast +6 -0
  4. package/dist/app.d.ts +6 -0
  5. package/dist/app.js +315 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/browser/cdp.d.ts +38 -0
  8. package/dist/browser/cdp.js +155 -0
  9. package/dist/browser/cdp.js.map +1 -0
  10. package/dist/browser/channelSelection.d.ts +65 -0
  11. package/dist/browser/channelSelection.js +202 -0
  12. package/dist/browser/channelSelection.js.map +1 -0
  13. package/dist/browser/display.d.ts +34 -0
  14. package/dist/browser/display.js +54 -0
  15. package/dist/browser/display.js.map +1 -0
  16. package/dist/browser/index.d.ts +205 -0
  17. package/dist/browser/index.js +1205 -0
  18. package/dist/browser/index.js.map +1 -0
  19. package/dist/browser/tuning/fox.d.ts +2 -0
  20. package/dist/browser/tuning/fox.js +83 -0
  21. package/dist/browser/tuning/fox.js.map +1 -0
  22. package/dist/browser/tuning/hbo.d.ts +2 -0
  23. package/dist/browser/tuning/hbo.js +237 -0
  24. package/dist/browser/tuning/hbo.js.map +1 -0
  25. package/dist/browser/tuning/hulu.d.ts +2 -0
  26. package/dist/browser/tuning/hulu.js +550 -0
  27. package/dist/browser/tuning/hulu.js.map +1 -0
  28. package/dist/browser/tuning/sling.d.ts +2 -0
  29. package/dist/browser/tuning/sling.js +518 -0
  30. package/dist/browser/tuning/sling.js.map +1 -0
  31. package/dist/browser/tuning/thumbnailRow.d.ts +2 -0
  32. package/dist/browser/tuning/thumbnailRow.js +108 -0
  33. package/dist/browser/tuning/thumbnailRow.js.map +1 -0
  34. package/dist/browser/tuning/tileClick.d.ts +2 -0
  35. package/dist/browser/tuning/tileClick.js +103 -0
  36. package/dist/browser/tuning/tileClick.js.map +1 -0
  37. package/dist/browser/tuning/youtubeTv.d.ts +2 -0
  38. package/dist/browser/tuning/youtubeTv.js +182 -0
  39. package/dist/browser/tuning/youtubeTv.js.map +1 -0
  40. package/dist/browser/video.d.ts +289 -0
  41. package/dist/browser/video.js +996 -0
  42. package/dist/browser/video.js.map +1 -0
  43. package/dist/channels/index.d.ts +3 -0
  44. package/dist/channels/index.js +392 -0
  45. package/dist/channels/index.js.map +1 -0
  46. package/dist/config/index.d.ts +53 -0
  47. package/dist/config/index.js +233 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/config/presets.d.ts +98 -0
  50. package/dist/config/presets.js +241 -0
  51. package/dist/config/presets.js.map +1 -0
  52. package/dist/config/profiles.d.ts +79 -0
  53. package/dist/config/profiles.js +245 -0
  54. package/dist/config/profiles.js.map +1 -0
  55. package/dist/config/providers.d.ts +120 -0
  56. package/dist/config/providers.js +450 -0
  57. package/dist/config/providers.js.map +1 -0
  58. package/dist/config/sites.d.ts +22 -0
  59. package/dist/config/sites.js +377 -0
  60. package/dist/config/sites.js.map +1 -0
  61. package/dist/config/userChannels.d.ts +178 -0
  62. package/dist/config/userChannels.js +543 -0
  63. package/dist/config/userChannels.js.map +1 -0
  64. package/dist/config/userConfig.d.ts +235 -0
  65. package/dist/config/userConfig.js +913 -0
  66. package/dist/config/userConfig.js.map +1 -0
  67. package/dist/hdhr/channelMap.d.ts +21 -0
  68. package/dist/hdhr/channelMap.js +82 -0
  69. package/dist/hdhr/channelMap.js.map +1 -0
  70. package/dist/hdhr/deviceId.d.ts +11 -0
  71. package/dist/hdhr/deviceId.js +84 -0
  72. package/dist/hdhr/deviceId.js.map +1 -0
  73. package/dist/hdhr/discover.d.ts +6 -0
  74. package/dist/hdhr/discover.js +155 -0
  75. package/dist/hdhr/discover.js.map +1 -0
  76. package/dist/hdhr/index.d.ts +9 -0
  77. package/dist/hdhr/index.js +87 -0
  78. package/dist/hdhr/index.js.map +1 -0
  79. package/dist/index.d.ts +1 -0
  80. package/dist/index.js +144 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/routes/assets.d.ts +6 -0
  83. package/dist/routes/assets.js +79 -0
  84. package/dist/routes/assets.js.map +1 -0
  85. package/dist/routes/auth.d.ts +6 -0
  86. package/dist/routes/auth.js +77 -0
  87. package/dist/routes/auth.js.map +1 -0
  88. package/dist/routes/channels.d.ts +6 -0
  89. package/dist/routes/channels.js +40 -0
  90. package/dist/routes/channels.js.map +1 -0
  91. package/dist/routes/components.d.ts +138 -0
  92. package/dist/routes/components.js +210 -0
  93. package/dist/routes/components.js.map +1 -0
  94. package/dist/routes/config.d.ts +72 -0
  95. package/dist/routes/config.js +1977 -0
  96. package/dist/routes/config.js.map +1 -0
  97. package/dist/routes/debug.d.ts +6 -0
  98. package/dist/routes/debug.js +274 -0
  99. package/dist/routes/debug.js.map +1 -0
  100. package/dist/routes/health.d.ts +6 -0
  101. package/dist/routes/health.js +85 -0
  102. package/dist/routes/health.js.map +1 -0
  103. package/dist/routes/hls.d.ts +6 -0
  104. package/dist/routes/hls.js +25 -0
  105. package/dist/routes/hls.js.map +1 -0
  106. package/dist/routes/index.d.ts +19 -0
  107. package/dist/routes/index.js +49 -0
  108. package/dist/routes/index.js.map +1 -0
  109. package/dist/routes/logs.d.ts +6 -0
  110. package/dist/routes/logs.js +164 -0
  111. package/dist/routes/logs.js.map +1 -0
  112. package/dist/routes/mpegts.d.ts +6 -0
  113. package/dist/routes/mpegts.js +19 -0
  114. package/dist/routes/mpegts.js.map +1 -0
  115. package/dist/routes/play.d.ts +6 -0
  116. package/dist/routes/play.js +18 -0
  117. package/dist/routes/play.js.map +1 -0
  118. package/dist/routes/playlist.d.ts +36 -0
  119. package/dist/routes/playlist.js +134 -0
  120. package/dist/routes/playlist.js.map +1 -0
  121. package/dist/routes/root.d.ts +6 -0
  122. package/dist/routes/root.js +2920 -0
  123. package/dist/routes/root.js.map +1 -0
  124. package/dist/routes/streams.d.ts +6 -0
  125. package/dist/routes/streams.js +88 -0
  126. package/dist/routes/streams.js.map +1 -0
  127. package/dist/routes/theme.d.ts +15 -0
  128. package/dist/routes/theme.js +275 -0
  129. package/dist/routes/theme.js.map +1 -0
  130. package/dist/routes/ui.d.ts +56 -0
  131. package/dist/routes/ui.js +354 -0
  132. package/dist/routes/ui.js.map +1 -0
  133. package/dist/service/commands.d.ts +41 -0
  134. package/dist/service/commands.js +391 -0
  135. package/dist/service/commands.js.map +1 -0
  136. package/dist/service/generators.d.ts +33 -0
  137. package/dist/service/generators.js +432 -0
  138. package/dist/service/generators.js.map +1 -0
  139. package/dist/service/index.d.ts +2 -0
  140. package/dist/service/index.js +7 -0
  141. package/dist/service/index.js.map +1 -0
  142. package/dist/streaming/clients.d.ts +48 -0
  143. package/dist/streaming/clients.js +114 -0
  144. package/dist/streaming/clients.js.map +1 -0
  145. package/dist/streaming/fmp4Segmenter.d.ts +61 -0
  146. package/dist/streaming/fmp4Segmenter.js +461 -0
  147. package/dist/streaming/fmp4Segmenter.js.map +1 -0
  148. package/dist/streaming/hls.d.ts +120 -0
  149. package/dist/streaming/hls.js +722 -0
  150. package/dist/streaming/hls.js.map +1 -0
  151. package/dist/streaming/hlsSegments.d.ts +54 -0
  152. package/dist/streaming/hlsSegments.js +162 -0
  153. package/dist/streaming/hlsSegments.js.map +1 -0
  154. package/dist/streaming/lifecycle.d.ts +33 -0
  155. package/dist/streaming/lifecycle.js +185 -0
  156. package/dist/streaming/lifecycle.js.map +1 -0
  157. package/dist/streaming/monitor.d.ts +74 -0
  158. package/dist/streaming/monitor.js +1310 -0
  159. package/dist/streaming/monitor.js.map +1 -0
  160. package/dist/streaming/mp4Parser.d.ts +74 -0
  161. package/dist/streaming/mp4Parser.js +566 -0
  162. package/dist/streaming/mp4Parser.js.map +1 -0
  163. package/dist/streaming/mpegts.d.ts +14 -0
  164. package/dist/streaming/mpegts.js +248 -0
  165. package/dist/streaming/mpegts.js.map +1 -0
  166. package/dist/streaming/registry.d.ts +119 -0
  167. package/dist/streaming/registry.js +127 -0
  168. package/dist/streaming/registry.js.map +1 -0
  169. package/dist/streaming/setup.d.ts +135 -0
  170. package/dist/streaming/setup.js +670 -0
  171. package/dist/streaming/setup.js.map +1 -0
  172. package/dist/streaming/showInfo.d.ts +30 -0
  173. package/dist/streaming/showInfo.js +362 -0
  174. package/dist/streaming/showInfo.js.map +1 -0
  175. package/dist/streaming/statusEmitter.d.ts +125 -0
  176. package/dist/streaming/statusEmitter.js +139 -0
  177. package/dist/streaming/statusEmitter.js.map +1 -0
  178. package/dist/types/index.d.ts +403 -0
  179. package/dist/types/index.js +6 -0
  180. package/dist/types/index.js.map +1 -0
  181. package/dist/utils/debugFilter.d.ts +38 -0
  182. package/dist/utils/debugFilter.js +157 -0
  183. package/dist/utils/debugFilter.js.map +1 -0
  184. package/dist/utils/delay.d.ts +6 -0
  185. package/dist/utils/delay.js +15 -0
  186. package/dist/utils/delay.js.map +1 -0
  187. package/dist/utils/errors.d.ts +15 -0
  188. package/dist/utils/errors.js +40 -0
  189. package/dist/utils/errors.js.map +1 -0
  190. package/dist/utils/evaluate.d.ts +51 -0
  191. package/dist/utils/evaluate.js +124 -0
  192. package/dist/utils/evaluate.js.map +1 -0
  193. package/dist/utils/ffmpeg.d.ts +65 -0
  194. package/dist/utils/ffmpeg.js +317 -0
  195. package/dist/utils/ffmpeg.js.map +1 -0
  196. package/dist/utils/fileLogger.d.ts +25 -0
  197. package/dist/utils/fileLogger.js +248 -0
  198. package/dist/utils/fileLogger.js.map +1 -0
  199. package/dist/utils/format.d.ts +16 -0
  200. package/dist/utils/format.js +46 -0
  201. package/dist/utils/format.js.map +1 -0
  202. package/dist/utils/html.d.ts +6 -0
  203. package/dist/utils/html.js +24 -0
  204. package/dist/utils/html.js.map +1 -0
  205. package/dist/utils/index.d.ts +15 -0
  206. package/dist/utils/index.js +20 -0
  207. package/dist/utils/index.js.map +1 -0
  208. package/dist/utils/logEmitter.d.ts +17 -0
  209. package/dist/utils/logEmitter.js +30 -0
  210. package/dist/utils/logEmitter.js.map +1 -0
  211. package/dist/utils/logger.d.ts +82 -0
  212. package/dist/utils/logger.js +219 -0
  213. package/dist/utils/logger.js.map +1 -0
  214. package/dist/utils/m3u.d.ts +32 -0
  215. package/dist/utils/m3u.js +148 -0
  216. package/dist/utils/m3u.js.map +1 -0
  217. package/dist/utils/morganStream.d.ts +7 -0
  218. package/dist/utils/morganStream.js +33 -0
  219. package/dist/utils/morganStream.js.map +1 -0
  220. package/dist/utils/platform.d.ts +64 -0
  221. package/dist/utils/platform.js +157 -0
  222. package/dist/utils/platform.js.map +1 -0
  223. package/dist/utils/retry.d.ts +15 -0
  224. package/dist/utils/retry.js +82 -0
  225. package/dist/utils/retry.js.map +1 -0
  226. package/dist/utils/streamContext.d.ts +28 -0
  227. package/dist/utils/streamContext.js +33 -0
  228. package/dist/utils/streamContext.js.map +1 -0
  229. package/dist/utils/version.d.ts +37 -0
  230. package/dist/utils/version.js +228 -0
  231. package/dist/utils/version.js.map +1 -0
  232. package/package.json +92 -0
  233. package/prismcast.png +0 -0
  234. package/prismcast.svg +74 -0
@@ -0,0 +1,670 @@
1
+ import { LOG, delay, extractDomain, formatError, registerAbortController, retryOperation, runWithStreamContext, spawnFFmpeg, startTimer } from "../utils/index.js";
2
+ import { getCurrentBrowser, getStream, minimizeBrowserWindow, registerManagedPage, unregisterManagedPage } from "../browser/index.js";
3
+ import { getNextStreamId, getStreamCount } from "./registry.js";
4
+ import { getProfileForChannel, getProfileForUrl, getProfiles, resolveProfile } from "../config/profiles.js";
5
+ import { initializePlayback, navigateToPage } from "../browser/video.js";
6
+ import { invalidateDirectUrl, resolveDirectUrl } from "../browser/channelSelection.js";
7
+ import { CONFIG } from "../config/index.js";
8
+ import { getEffectiveViewport } from "../config/presets.js";
9
+ import { getProviderDisplayName } from "../config/providers.js";
10
+ import { monitorPlaybackHealth } from "./monitor.js";
11
+ import { pipeline } from "node:stream/promises";
12
+ import { resizeAndMinimizeWindow } from "../browser/cdp.js";
13
+ /* This module contains the common stream setup logic for HLS streaming. The core logic is split into two functions:
14
+ *
15
+ * 1. createPageWithCapture(): Creates a browser page, starts media capture, navigates to the URL, and sets up video playback. This is the reusable core that both
16
+ * initial stream setup and tab replacement recovery use.
17
+ *
18
+ * 2. setupStream(): Orchestrates stream creation by calling createPageWithCapture(), then registering the stream and starting the health monitor. This is the
19
+ * entry point for new stream requests.
20
+ *
21
+ * The separation allows tab replacement recovery (in monitor.ts) to reuse the capture setup logic without duplicating code. When a browser tab becomes unresponsive,
22
+ * the recovery handler can close the old tab, call createPageWithCapture() to create a fresh one, and continue with the same stream ID.
23
+ *
24
+ * createPageWithCapture() handles:
25
+ * - Browser page creation with CSP bypass
26
+ * - Media stream initialization (native fMP4 or WebM+FFmpeg)
27
+ * - Navigation with retry
28
+ * - Video element detection and playback setup
29
+ *
30
+ * setupStream() additionally handles:
31
+ * - Request validation (URL format, concurrent stream limit)
32
+ * - Stream registration
33
+ * - Health monitor startup
34
+ * - Cleanup function creation
35
+ */
36
+ // Native fMP4 capture uses MP4/AAC for direct HLS segmentation without transcoding.
37
+ const NATIVE_FMP4_MIME_TYPE = "video/mp4;codecs=avc1,mp4a.40.2";
38
+ // WebM+FFmpeg capture uses WebM/H264+Opus, which requires FFmpeg to transcode audio to AAC. This mode is more stable for long recordings because Chrome's native fMP4
39
+ // MediaRecorder can become unstable after 20-30 minutes.
40
+ const WEBM_FFMPEG_MIME_TYPE = "video/webm;codecs=h264,opus";
41
+ // Capture initialization queue. Chrome's tabCapture extension can only initialize one capture at a time — concurrent getStream() calls fail with "Cannot capture a
42
+ // tab with an active stream." We serialize capture initialization using a promise chain so requests execute sequentially. Once a capture is established, it runs
43
+ // concurrently with other captures without issue.
44
+ let captureQueue = Promise.resolve();
45
+ // Maximum number of times createPageWithCapture() will retry when it detects that the page was closed while waiting in the capture queue (e.g., due to a browser
46
+ // crash). An explicit guard prevents unbounded recursion.
47
+ const MAX_PAGE_CLOSED_RETRIES = 3;
48
+ /**
49
+ * Error thrown when stream setup fails. Includes HTTP status code and user-friendly message for the response.
50
+ */
51
+ export class StreamSetupError extends Error {
52
+ statusCode;
53
+ userMessage;
54
+ constructor(message, statusCode, userMessage) {
55
+ super(message);
56
+ this.name = "StreamSetupError";
57
+ this.statusCode = statusCode;
58
+ this.userMessage = userMessage;
59
+ }
60
+ }
61
+ // Request ID Generation.
62
+ /**
63
+ * Generates a short alphanumeric request ID for log correlation. The ID is 6 characters to keep log messages readable while providing enough uniqueness for
64
+ * practical debugging. With 36 possible characters (a-z, 0-9), there are 2.1 billion possible IDs, making collisions unlikely during any debugging session.
65
+ * @returns A 6-character alphanumeric string.
66
+ */
67
+ function generateRequestId() {
68
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
69
+ let result = "";
70
+ for (let i = 0; i < 6; i++) {
71
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
72
+ }
73
+ return result;
74
+ }
75
+ /**
76
+ * Generates a concise stream identifier for logging purposes. The identifier combines the channel name or hostname with a unique request ID, making it easy to
77
+ * trace related log messages. We prefer the channel name when available because it's more meaningful than a hostname.
78
+ * @param channelName - The channel name if streaming a named channel.
79
+ * @param url - The URL being streamed.
80
+ * @returns A concise stream identifier.
81
+ */
82
+ export function generateStreamId(channelName, url) {
83
+ const requestId = generateRequestId();
84
+ // If we have a channel name, use it as the prefix. Channel names are short and meaningful (e.g., "nbc", "espn").
85
+ if (channelName) {
86
+ return [channelName, "-", requestId].join("");
87
+ }
88
+ // For direct URL requests, use the concise domain as the prefix.
89
+ if (url) {
90
+ return [extractDomain(url), "-", requestId].join("");
91
+ }
92
+ // Fallback when neither channel name nor URL is available. This shouldn't happen in normal operation but provides a valid ID for edge cases.
93
+ return ["unknown-", requestId].join("");
94
+ }
95
+ // URL Validation.
96
+ /**
97
+ * Validates a URL before attempting to navigate to it. This function checks for supported protocols, prevents local file access, and ensures the URL is properly
98
+ * formatted. Validating URLs before navigation prevents security issues and provides clear error messages.
99
+ * @param url - The URL to validate.
100
+ * @returns Validation result with optional reason for failure.
101
+ */
102
+ export function validateStreamUrl(url) {
103
+ // A URL is required. This catches both undefined and empty string.
104
+ if (!url) {
105
+ return { reason: "URL is required.", valid: false };
106
+ }
107
+ try {
108
+ const parsed = new URL(url);
109
+ // We support HTTP, HTTPS, and chrome: protocols. HTTP and HTTPS are standard web protocols, while chrome: URLs are used for internal pages like
110
+ // chrome://gpu for diagnostics. Other protocols (javascript:, data:, blob:) are not supported.
111
+ const allowedProtocols = ["chrome:", "http:", "https:"];
112
+ if (!allowedProtocols.includes(parsed.protocol)) {
113
+ return { reason: ["Unsupported protocol: ", parsed.protocol].join(""), valid: false };
114
+ }
115
+ // Local file access is explicitly blocked for security reasons. While the URL constructor wouldn't typically parse a file: URL through the protocol check
116
+ // above, we check explicitly for defense in depth.
117
+ if (parsed.protocol === "file:") {
118
+ return { reason: "Local file access is not permitted.", valid: false };
119
+ }
120
+ return { valid: true };
121
+ }
122
+ catch (_error) {
123
+ // The URL constructor throws for invalid URLs. We catch this and return a clear error message.
124
+ return { reason: "Invalid URL format.", valid: false };
125
+ }
126
+ }
127
+ // Page and Capture Creation.
128
+ /**
129
+ * Creates a browser page with media capture and navigates to the URL. This is the reusable core function used by both initial stream setup and tab replacement
130
+ * recovery. It handles:
131
+ * - Creating a new browser page with CSP bypass
132
+ * - Initializing media capture (native fMP4 or WebM+FFmpeg)
133
+ * - Navigating to the URL with retry
134
+ * - Setting up video playback via navigateToPage() + initializePlayback()
135
+ *
136
+ * The caller is responsible for:
137
+ * - Creating the segmenter and piping captureStream to it
138
+ * - Registering/updating the stream in the registry
139
+ * - Starting/updating the health monitor
140
+ * - Handling cleanup on failure
141
+ *
142
+ * @param options - Options for page and capture creation.
143
+ * @returns The page, context, capture stream, and FFmpeg process (if any).
144
+ * @throws Error if page creation, capture initialization, or navigation fails.
145
+ */
146
+ export async function createPageWithCapture(options) {
147
+ const captureElapsed = startTimer();
148
+ const { comment, onFFmpegError, profile, streamId, url } = options;
149
+ // Create browser page.
150
+ const browser = await getCurrentBrowser();
151
+ const page = await browser.newPage();
152
+ registerManagedPage(page);
153
+ await page.setBypassCSP(true);
154
+ // Select MIME type based on capture mode. FFmpeg mode is more stable for long recordings because Chrome's native fMP4 MediaRecorder can become unstable.
155
+ const useFFmpeg = CONFIG.streaming.captureMode === "ffmpeg";
156
+ const captureMimeType = useFFmpeg ? WEBM_FFMPEG_MIME_TYPE : NATIVE_FMP4_MIME_TYPE;
157
+ // Track the output stream that will be sent to the segmenter and FFmpeg process if used. Also track the raw capture stream separately - it must be destroyed
158
+ // before closing the page to ensure chrome.tabCapture releases the capture.
159
+ let outputStream;
160
+ let rawCaptureStream = null;
161
+ let ffmpegProcess = null;
162
+ // Capture queue release function, hoisted here so both the try and catch blocks can access it. Initialized in the try block when the queue entry is created.
163
+ // The once-guard prevents double-releasing from multiple code paths (success handler, catch block, timeout).
164
+ let captureQueueReleased = false;
165
+ let releaseCaptureQueue = () => { };
166
+ const releaseCaptureOnce = () => {
167
+ if (!captureQueueReleased) {
168
+ captureQueueReleased = true;
169
+ releaseCaptureQueue();
170
+ }
171
+ };
172
+ // Initialize media stream capture.
173
+ try {
174
+ const streamOptions = {
175
+ audio: true,
176
+ audioBitsPerSecond: CONFIG.streaming.audioBitsPerSecond,
177
+ mimeType: captureMimeType,
178
+ video: true,
179
+ videoBitsPerSecond: CONFIG.streaming.videoBitsPerSecond,
180
+ videoConstraints: {
181
+ mandatory: {
182
+ maxFrameRate: 60,
183
+ maxHeight: getEffectiveViewport(CONFIG).height,
184
+ maxWidth: getEffectiveViewport(CONFIG).width,
185
+ minFrameRate: Math.max(30, Math.min(60, CONFIG.streaming.frameRate)),
186
+ minHeight: getEffectiveViewport(CONFIG).height,
187
+ minWidth: getEffectiveViewport(CONFIG).width
188
+ }
189
+ }
190
+ };
191
+ // Serialize capture initialization. Wait for any previous capture to finish before calling getStream(), because Chrome's tabCapture extension rejects
192
+ // concurrent initialization attempts. On success, the lock is released immediately so the next caller can proceed. On failure, the lock is held until the
193
+ // catch block decides what to do — the catch block releases the lock after handling the error.
194
+ const previousCapture = captureQueue;
195
+ captureQueue = new Promise((resolve) => {
196
+ releaseCaptureQueue = resolve;
197
+ });
198
+ // Guard against a permanently hung predecessor. If the previous capture doesn't complete within the navigation timeout, release our queue position and let the
199
+ // caller's error handling deal with it. This prevents a single stuck getStream() from blocking all future captures indefinitely.
200
+ try {
201
+ await Promise.race([
202
+ previousCapture,
203
+ new Promise((_, reject) => {
204
+ setTimeout(() => {
205
+ reject(new Error("Capture queue wait timed out."));
206
+ }, CONFIG.streaming.navigationTimeout);
207
+ })
208
+ ]);
209
+ }
210
+ catch (error) {
211
+ // Release our queue position so subsequent captures aren't blocked by our failure.
212
+ releaseCaptureOnce();
213
+ throw error;
214
+ }
215
+ // After the queue wait, verify our page is still connected. If Chrome crashed while we were waiting, our page is dead and we need to start over with a
216
+ // fresh page on the new browser. Release our queue position first so subsequent callers aren't blocked.
217
+ if (page.isClosed()) {
218
+ releaseCaptureOnce();
219
+ unregisterManagedPage(page);
220
+ const retryCount = options._pageClosedRetries ?? 0;
221
+ if (retryCount >= MAX_PAGE_CLOSED_RETRIES) {
222
+ throw new Error("Browser crashed too many times during capture initialization.");
223
+ }
224
+ return await createPageWithCapture({ ...options, _pageClosedRetries: retryCount + 1 });
225
+ }
226
+ const streamPromise = getStream(page, streamOptions);
227
+ // Release the queue on success only. On failure, the catch block handles the release. The rejection handler is a no-op to suppress unhandled rejection
228
+ // warnings; the actual error handling happens in the catch block below.
229
+ void streamPromise.then(() => { releaseCaptureOnce(); }, () => { });
230
+ const timeoutPromise = new Promise((_, reject) => {
231
+ setTimeout(() => {
232
+ reject(new Error("Stream initialization timed out."));
233
+ }, CONFIG.streaming.navigationTimeout);
234
+ });
235
+ const stream = await Promise.race([streamPromise, timeoutPromise]);
236
+ // Store the raw capture stream. This must be destroyed before closing the page.
237
+ rawCaptureStream = stream;
238
+ // For FFmpeg mode, spawn FFmpeg to transcode the WebM stream to fMP4. FFmpeg copies the H264 video and transcodes Opus audio to AAC.
239
+ if (useFFmpeg) {
240
+ const ffmpeg = spawnFFmpeg(CONFIG.streaming.audioBitsPerSecond, (error) => {
241
+ LOG.error("FFmpeg process error: %s.", formatError(error));
242
+ if (onFFmpegError) {
243
+ onFFmpegError(error);
244
+ }
245
+ }, streamId, comment);
246
+ ffmpegProcess = ffmpeg;
247
+ // Handle pipe errors on stdout. Stdin errors are handled by pipeline() below.
248
+ ffmpeg.stdout.on("error", (error) => {
249
+ const errorMessage = formatError(error);
250
+ if (errorMessage.includes("EPIPE")) {
251
+ LOG.debug("streaming:ffmpeg", "FFmpeg stdout pipe closed: %s.", errorMessage);
252
+ }
253
+ else {
254
+ LOG.error("FFmpeg stdout pipe error: %s.", errorMessage);
255
+ ffmpeg.kill();
256
+ if (onFFmpegError) {
257
+ onFFmpegError(error);
258
+ }
259
+ }
260
+ });
261
+ // Pipe the WebM capture stream to FFmpeg's stdin using pipeline() for proper cleanup. When FFmpeg is killed during tab replacement, pipeline() automatically
262
+ // destroys the source stream, preventing "write after end" errors that would occur with .pipe().
263
+ pipeline(stream, ffmpeg.stdin).catch((error) => {
264
+ const errorMessage = formatError(error);
265
+ // EPIPE, "write after end", and "Premature close" errors are expected during cleanup when FFmpeg is killed or the capture stream is destroyed.
266
+ if (errorMessage.includes("EPIPE") || errorMessage.includes("write after end") || errorMessage.includes("Premature close")) {
267
+ return;
268
+ }
269
+ // Unexpected pipeline errors require cleanup.
270
+ LOG.error("Capture pipeline error: %s.", errorMessage);
271
+ ffmpeg.kill();
272
+ if (onFFmpegError) {
273
+ onFFmpegError(error instanceof Error ? error : new Error(String(error)));
274
+ }
275
+ });
276
+ // Use FFmpeg's stdout (fMP4 output) as the output stream for segmentation.
277
+ outputStream = ffmpeg.stdout;
278
+ }
279
+ else {
280
+ // Native fMP4 mode: Use the raw capture stream directly. In this mode, rawCaptureStream and outputStream are the same object.
281
+ outputStream = rawCaptureStream;
282
+ }
283
+ }
284
+ catch (error) {
285
+ // Clean up on capture initialization failure. Destroy the raw capture stream first to ensure chrome.tabCapture releases the capture.
286
+ if (rawCaptureStream && !rawCaptureStream.destroyed) {
287
+ rawCaptureStream.destroy();
288
+ }
289
+ unregisterManagedPage(page);
290
+ if (!page.isClosed()) {
291
+ page.close().catch(() => { });
292
+ }
293
+ const errorMessage = formatError(error);
294
+ // Stale capture state is unrecoverable. The "Cannot capture a tab with an active stream" error occurs inside puppeteer-stream's second lock section, which
295
+ // has no try/finally. The internal mutex is permanently leaked — all subsequent getStream() calls will hang on it. Chrome restart cannot fix module-level
296
+ // state, so the only recourse is a full process restart. Release the capture queue so other callers aren't left hanging, then exit.
297
+ if (errorMessage.includes("Cannot capture a tab with an active stream")) {
298
+ LOG.error("Stale capture state detected. puppeteer-stream's internal capture mutex is now permanently locked. The capture system is unrecoverable. " +
299
+ "Exiting so the service manager can restart with a clean module state.");
300
+ releaseCaptureOnce();
301
+ setTimeout(() => process.exit(1), 100);
302
+ throw error;
303
+ }
304
+ // For non-stale errors, release the capture queue so subsequent callers can proceed.
305
+ releaseCaptureOnce();
306
+ throw error;
307
+ }
308
+ // Navigate and set up playback. For noVideo profiles, just navigate without video setup.
309
+ let context;
310
+ let usedDirectUrl = false;
311
+ try {
312
+ if (!profile.noVideo) {
313
+ // Check for a direct watch URL. If available, navigate directly to it and skip channel selection, avoiding guide page navigation entirely. On failure,
314
+ // the cache entry is invalidated in the catch block so the outer retry loop (in streaming/hls.ts) re-invokes with the guide URL.
315
+ const directUrl = await resolveDirectUrl(profile, page);
316
+ usedDirectUrl = !!directUrl;
317
+ const navigationUrl = directUrl ?? url;
318
+ // Phase 1: Navigate to the page with retry. The 10-second navigationTimeout is appropriate for page loads, and retryOperation correctly reloads the page on
319
+ // genuine navigation failures. Navigation is wrapped in retryOperation separately from channel selection so the timeout does not race with the internal click
320
+ // retry loops in channel selection strategies (guideGrid can take 15-20 seconds for binary search + click retries).
321
+ await retryOperation(async () => {
322
+ await navigateToPage(page, navigationUrl, profile);
323
+ }, CONFIG.streaming.maxNavigationRetries, CONFIG.streaming.navigationTimeout, "page navigation for " + navigationUrl, undefined, () => page.isClosed());
324
+ // Phase 2: Channel selection + video setup. When navigating to a cached direct URL, skip channel selection since the URL already targets the correct
325
+ // channel. Runs after navigation succeeds with no outer timeout racing against internal click retries. Each sub-step (selectChannel, waitForVideoReady,
326
+ // etc.) has its own internal timeout via videoTimeout and click retry constants. For guideGrid strategies, a channel selection failure triggers an overlay
327
+ // dismiss and retry, which doubles the channel selection time budget. The 45-second safety-net timeout accommodates this retry while still preventing
328
+ // pathological hangs if multiple internal timeouts chain sequentially.
329
+ const PLAYBACK_INIT_TIMEOUT = 45000;
330
+ const tuneResult = await Promise.race([
331
+ initializePlayback(page, profile, usedDirectUrl),
332
+ new Promise((_, reject) => {
333
+ setTimeout(() => {
334
+ reject(new Error("Playback initialization timed out after " + String(PLAYBACK_INIT_TIMEOUT) + "ms."));
335
+ }, PLAYBACK_INIT_TIMEOUT);
336
+ })
337
+ ]);
338
+ context = tuneResult.context;
339
+ }
340
+ else {
341
+ await page.goto(url);
342
+ context = page;
343
+ }
344
+ }
345
+ catch (error) {
346
+ // If a cached direct URL was used, invalidate it so the next attempt falls through to guide navigation.
347
+ if (usedDirectUrl) {
348
+ invalidateDirectUrl(profile);
349
+ }
350
+ // Clean up on navigation or playback initialization failure. Destroy the raw capture stream first to ensure chrome.tabCapture releases the capture.
351
+ if (!rawCaptureStream.destroyed) {
352
+ rawCaptureStream.destroy();
353
+ }
354
+ if (ffmpegProcess) {
355
+ ffmpegProcess.kill();
356
+ }
357
+ unregisterManagedPage(page);
358
+ if (!page.isClosed()) {
359
+ page.close().catch(() => { });
360
+ }
361
+ // Re-minimize the browser window. Navigation may have un-minimized it (new tab activation on macOS), and without this the window stays visible after the
362
+ // failed attempt. Fire-and-forget since we're about to throw.
363
+ minimizeBrowserWindow().catch(() => { });
364
+ throw error;
365
+ }
366
+ // Resize and minimize window.
367
+ await resizeAndMinimizeWindow(page, !profile.noVideo);
368
+ LOG.debug("timing:startup", "Page with capture ready. Total: %sms.", captureElapsed());
369
+ return {
370
+ captureStream: outputStream,
371
+ context,
372
+ ffmpegProcess,
373
+ page,
374
+ rawCaptureStream
375
+ };
376
+ }
377
+ // URL Redirect Resolution.
378
+ /**
379
+ * Resolves a URL's final destination by following HTTP redirects. This is used for profile detection when a channel's URL belongs to an indirection service (e.g.,
380
+ * FruitDeepLinks) whose domain has no profile mapping. By following redirects, we discover the actual streaming site's domain and can resolve the correct profile.
381
+ *
382
+ * Uses a HEAD request to avoid downloading response bodies. The 3-second timeout ensures stream startup isn't blocked by slow or unreachable indirection services.
383
+ *
384
+ * @param url - The URL to resolve.
385
+ * @returns The final URL after following all redirects, or null on any error.
386
+ */
387
+ async function resolveRedirectUrl(url) {
388
+ try {
389
+ const response = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(3000) });
390
+ return response.url;
391
+ }
392
+ catch {
393
+ return null;
394
+ }
395
+ }
396
+ // Stream Setup.
397
+ /**
398
+ * Sets up a stream: validates input, creates browser page, initializes capture, navigates to URL, and starts health monitoring.
399
+ *
400
+ * This function handles all common stream setup logic. The caller is responsible for:
401
+ * - Connecting the returned captureStream to the appropriate output (HTTP response, FFmpeg, etc.)
402
+ * - Registering the stream in the registry
403
+ * - Triggering cleanup when the stream ends
404
+ *
405
+ * @param options - Stream configuration options.
406
+ * @param onCircuitBreak - Callback invoked when the circuit breaker trips (stream unrecoverable).
407
+ * @returns Setup result with capture stream, cleanup function, and metadata.
408
+ * @throws StreamSetupError if setup fails with appropriate status code and message.
409
+ */
410
+ export async function setupStream(options, onCircuitBreak) {
411
+ const { channel, channelName, channelSelector, clickSelector, clickToPlay, noVideo, onTabReplacementFactory, profileOverride, url } = options;
412
+ // Generate stream identifiers early so all log messages include them.
413
+ const streamId = generateStreamId(channelName, url);
414
+ const numericStreamId = getNextStreamId();
415
+ const startTime = new Date();
416
+ // Create and register the AbortController for this stream. This allows pending evaluate calls to be cancelled immediately when the stream is terminated.
417
+ const abortController = new AbortController();
418
+ registerAbortController(streamId, abortController);
419
+ // Resolve the profile for this stream. If the original URL's domain has no mapping (profileName === "default"), try following HTTP redirects to discover the
420
+ // actual destination domain. This supports indirection services like FruitDeepLinks that use redirect URLs to route to the actual streaming site.
421
+ let profileResult = channel ? getProfileForChannel(channel) : getProfileForUrl(url);
422
+ if (profileResult.profileName === "default") {
423
+ const urlToResolve = channel?.url ?? url;
424
+ if (urlToResolve) {
425
+ const resolvedUrl = await resolveRedirectUrl(urlToResolve);
426
+ if (resolvedUrl && (resolvedUrl !== urlToResolve)) {
427
+ const redirectResult = getProfileForUrl(resolvedUrl);
428
+ if (redirectResult.profileName !== "default") {
429
+ profileResult = redirectResult;
430
+ LOG.debug("streaming:setup", "Resolved redirect for profile detection: %s → %s (%s).", urlToResolve, resolvedUrl, redirectResult.profileName);
431
+ }
432
+ }
433
+ }
434
+ }
435
+ let profile = profileResult.profile;
436
+ let profileName = profileResult.profileName;
437
+ // Wrap the setup in stream context for log correlation.
438
+ return runWithStreamContext({ channelName: channel?.name, streamId, url }, async () => {
439
+ // Apply profile override if specified.
440
+ if (profileOverride) {
441
+ const validProfiles = getProfiles().map((p) => p.name);
442
+ if (validProfiles.includes(profileOverride)) {
443
+ profile = resolveProfile(profileOverride);
444
+ profileName = profileOverride;
445
+ LOG.debug("streaming:setup", "Profile overridden to '%s' via query parameter.", profileOverride);
446
+ }
447
+ else {
448
+ LOG.warn("Unknown profile override '%s', using resolved profile.", profileOverride);
449
+ }
450
+ }
451
+ // Apply noVideo override if specified.
452
+ if (noVideo) {
453
+ profile = { ...profile, noVideo: true };
454
+ }
455
+ // Merge the ad-hoc channel selector into the profile if provided. This must happen after the profile override block above, which replaces the profile object
456
+ // wholesale and would discard an earlier merge. For predefined channels, getProfileForChannel already handles the merge from channel.channelSelector.
457
+ if (channelSelector) {
458
+ profile = { ...profile, channelSelector };
459
+ }
460
+ // Merge the ad-hoc clickToPlay and clickSelector options into the profile. clickSelector implies clickToPlay. For ad-hoc streams, these enable clicking an
461
+ // element to start playback - either the video element (clickToPlay alone) or a play button overlay (clickToPlay + clickSelector).
462
+ if (clickToPlay || clickSelector) {
463
+ profile = { ...profile, clickToPlay: true, ...(clickSelector ? { clickSelector } : {}) };
464
+ }
465
+ // Compute the metadata comment for FFmpeg. Prefer the friendly channel name, fall back to the channel key, or extract the domain from the URL.
466
+ const metadataComment = channel?.name ?? channelName ?? extractDomain(url);
467
+ // Compute the friendly provider display name once for use in both the monitor and the setup result.
468
+ const providerName = getProviderDisplayName(url);
469
+ // Create the tab replacement handler if a factory was provided. This is done after profile resolution so the handler has access to the final profile.
470
+ const onTabReplacement = onTabReplacementFactory ? onTabReplacementFactory(numericStreamId, streamId, profile, metadataComment) : undefined;
471
+ // Validate URL.
472
+ const validation = validateStreamUrl(url);
473
+ if (!validation.valid) {
474
+ LOG.error("Invalid URL requested: %s - %s.", url, validation.reason ?? "Unknown error");
475
+ throw new StreamSetupError(["Invalid URL: ", validation.reason ?? "Unknown error"].join(""), 400, validation.reason ?? "Invalid URL.");
476
+ }
477
+ // Check concurrent stream limit.
478
+ if (getStreamCount() >= CONFIG.streaming.maxConcurrentStreams) {
479
+ LOG.warn("Concurrent stream limit reached (%s/%s). Rejecting request.", getStreamCount(), CONFIG.streaming.maxConcurrentStreams);
480
+ throw new StreamSetupError("Concurrent stream limit reached.", 503, ["Maximum concurrent streams (", String(CONFIG.streaming.maxConcurrentStreams), ") reached. Try again later."].join(""));
481
+ }
482
+ // Create page and start capture using the shared function. This handles browser page creation, capture initialization, FFmpeg spawning, and navigation with retry.
483
+ let captureResult;
484
+ try {
485
+ captureResult = await createPageWithCapture({
486
+ comment: metadataComment,
487
+ onFFmpegError: onCircuitBreak,
488
+ profile,
489
+ streamId,
490
+ url
491
+ });
492
+ }
493
+ catch (error) {
494
+ // createPageWithCapture handles its own cleanup on failure (closes page, kills FFmpeg).
495
+ const errorMessage = formatError(error);
496
+ const isBenign = errorMessage.toLowerCase().includes("abort") || errorMessage.toLowerCase().includes("session closed");
497
+ if (!isBenign) {
498
+ LOG.error("Stream setup failed for %s: %s.", url, errorMessage);
499
+ }
500
+ // Capture infrastructure errors should return 503 to signal Channels DVR to back off. These include Chrome capture state issues, queue timeouts, and stream
501
+ // initialization failures. Using 503 with Retry-After prevents retry storms when there's a systemic issue.
502
+ const captureErrorPatterns = ["Cannot capture", "timed out", "Capture queue"];
503
+ const isCaptureError = captureErrorPatterns.some((pattern) => errorMessage.includes(pattern));
504
+ throw new StreamSetupError("Stream error.", isCaptureError ? 503 : 500, "Failed to start stream.");
505
+ }
506
+ const { captureStream, context, ffmpegProcess, page, rawCaptureStream } = captureResult;
507
+ // Monitor stream info for status updates.
508
+ const monitorStreamInfo = {
509
+ channelName: channel?.name ?? null,
510
+ numericStreamId,
511
+ providerName,
512
+ startTime
513
+ };
514
+ // Start the health monitor for this stream.
515
+ const stopMonitor = monitorPlaybackHealth(page, context, profile, url, streamId, monitorStreamInfo, onCircuitBreak, onTabReplacement);
516
+ // Cleanup function. Releases all resources associated with the stream. Idempotent - safe to call multiple times.
517
+ let cleanupCompleted = false;
518
+ const cleanup = async () => {
519
+ if (cleanupCompleted) {
520
+ return;
521
+ }
522
+ cleanupCompleted = true;
523
+ // Stop the health monitor first.
524
+ stopMonitor();
525
+ // Destroy the raw capture stream BEFORE closing the page. This triggers puppeteer-stream's close handler while the browser is still connected, ensuring
526
+ // STOP_RECORDING is called and chrome.tabCapture releases the capture. Without this, subsequent getStream() calls may hang with "active stream" errors.
527
+ if (!rawCaptureStream.destroyed) {
528
+ rawCaptureStream.destroy();
529
+ }
530
+ // Kill the FFmpeg process if using WebM+FFmpeg mode.
531
+ if (ffmpegProcess) {
532
+ ffmpegProcess.kill();
533
+ }
534
+ // Unregister from managed pages.
535
+ unregisterManagedPage(page);
536
+ // Close the browser page (fire-and-forget to avoid blocking on stuck pages).
537
+ if (!page.isClosed()) {
538
+ page.close().catch((error) => {
539
+ LOG.debug("streaming:setup", "Page close error during cleanup: %s.", formatError(error));
540
+ });
541
+ }
542
+ // Re-minimize the browser window.
543
+ await minimizeBrowserWindow();
544
+ };
545
+ // Return the setup result.
546
+ return {
547
+ captureStream,
548
+ channelName: channel?.name ?? null,
549
+ cleanup,
550
+ ffmpegProcess,
551
+ numericStreamId,
552
+ page,
553
+ profile,
554
+ profileName,
555
+ providerName,
556
+ rawCaptureStream,
557
+ startTime,
558
+ stopMonitor,
559
+ streamId,
560
+ url
561
+ };
562
+ });
563
+ }
564
+ // Startup Capture Verification.
565
+ /**
566
+ * Verifies that Chrome's capture system is functional before the server starts accepting requests. This detects stale tabCapture state left over from a previous
567
+ * Chrome process — common during quick service restarts where the old process hasn't fully exited before the new one launches. Without this probe, the first stream
568
+ * request would trigger the runtime stale capture handler, which exits the process because the puppeteer-stream mutex is permanently leaked.
569
+ *
570
+ * The probe creates a temporary page, attempts a short capture, and tears down both cleanly. A 500ms delay after destroying the capture stream allows
571
+ * puppeteer-stream's fire-and-forget STOP_RECORDING chain to complete before closing the page, preventing the stale capture cascade on the first real request.
572
+ *
573
+ * After a system reboot, Chrome's display stack or capture extension may not be ready when the service manager starts PrismCast. The probe retries up to
574
+ * PROBE_MAX_ATTEMPTS times with a delay between attempts, giving the system time to settle before giving up. This prevents a rapid restart storm where the service
575
+ * manager relaunches PrismCast repeatedly, each attempt orphaning a Chrome process and degrading the environment further.
576
+ *
577
+ * If stale capture state is detected, the process exits immediately — Chrome restart cannot fix the leaked mutex, only a fresh process can.
578
+ */
579
+ export async function verifyCaptureSystem() {
580
+ const PROBE_MAX_ATTEMPTS = 3;
581
+ const PROBE_RETRY_DELAY = 5000;
582
+ const PROBE_TIMEOUT = 5000;
583
+ for (let attempt = 1; attempt <= PROBE_MAX_ATTEMPTS; attempt++) {
584
+ // eslint-disable-next-line no-await-in-loop -- Sequential retries are intentional; each probe must complete before deciding whether to retry.
585
+ const result = await attemptCaptureProbe(PROBE_TIMEOUT);
586
+ // Probe succeeded.
587
+ if (result === null) {
588
+ return;
589
+ }
590
+ // Stale capture state is unrecoverable. The error occurs inside puppeteer-stream's second lock section, which has no try/finally — the internal mutex is
591
+ // permanently leaked. All subsequent getStream() calls will hang on it. Chrome restart cannot fix module-level state, so exit and let the service manager
592
+ // restart with a clean process.
593
+ if (result.includes("Cannot capture a tab with an active stream")) {
594
+ LOG.error("Startup probe detected stale capture state. puppeteer-stream's internal capture mutex is now permanently locked. Exiting so the service " +
595
+ "manager can restart with a clean module state.");
596
+ process.exit(1);
597
+ }
598
+ // If we have retries remaining, log a warning and wait before the next attempt.
599
+ if (attempt < PROBE_MAX_ATTEMPTS) {
600
+ LOG.warn("Capture probe attempt %d of %d failed: %s. Retrying in %ds.", attempt, PROBE_MAX_ATTEMPTS, result, PROBE_RETRY_DELAY / 1000);
601
+ // eslint-disable-next-line no-await-in-loop -- Deliberate delay between sequential retry attempts.
602
+ await delay(PROBE_RETRY_DELAY);
603
+ }
604
+ else {
605
+ throw new Error("Capture system verification failed after " + String(PROBE_MAX_ATTEMPTS) + " attempts: " + result);
606
+ }
607
+ }
608
+ }
609
+ /**
610
+ * Executes a single capture probe attempt. Creates a temporary page, tries to start a capture stream, and tears everything down cleanly.
611
+ * @param timeout - Maximum time in milliseconds to wait for getStream() to respond.
612
+ * @returns Null on success, or an error message string on failure.
613
+ */
614
+ async function attemptCaptureProbe(timeout) {
615
+ const browser = await getCurrentBrowser();
616
+ const page = await browser.newPage();
617
+ registerManagedPage(page);
618
+ try {
619
+ // Use the same capture MIME type and viewport constraints as the runtime. The stale state error occurs at the tabCapture API level before encoding matters,
620
+ // but matching the runtime configuration ensures the probe exercises the exact same getStream() parameters.
621
+ const useFFmpeg = CONFIG.streaming.captureMode === "ffmpeg";
622
+ const captureMimeType = useFFmpeg ? WEBM_FFMPEG_MIME_TYPE : NATIVE_FMP4_MIME_TYPE;
623
+ const streamOptions = {
624
+ audio: true,
625
+ mimeType: captureMimeType,
626
+ video: true,
627
+ videoConstraints: {
628
+ mandatory: {
629
+ maxFrameRate: 30,
630
+ maxHeight: getEffectiveViewport(CONFIG).height,
631
+ maxWidth: getEffectiveViewport(CONFIG).width,
632
+ minFrameRate: 30,
633
+ minHeight: getEffectiveViewport(CONFIG).height,
634
+ minWidth: getEffectiveViewport(CONFIG).width
635
+ }
636
+ }
637
+ };
638
+ const stream = await Promise.race([
639
+ getStream(page, streamOptions),
640
+ new Promise((_, reject) => {
641
+ setTimeout(() => {
642
+ reject(new Error("Capture probe timed out."));
643
+ }, timeout);
644
+ })
645
+ ]);
646
+ // Capture succeeded — the system is functional. Destroy the stream before closing the page to ensure chrome.tabCapture releases the capture cleanly.
647
+ const readable = stream;
648
+ readable.destroy();
649
+ // Wait for puppeteer-stream's capture cleanup chain to complete. readable.destroy() triggers STOP_RECORDING via the close handler, but the call is
650
+ // fire-and-forget. The async chain (STOP_RECORDING → recorder.stop() → onstop → track.stop()) must finish before closing the page, or Chrome's tabCapture
651
+ // state may linger and cause "Cannot capture a tab with an active stream" errors on the first real stream request.
652
+ await delay(500);
653
+ unregisterManagedPage(page);
654
+ if (!page.isClosed()) {
655
+ await page.close();
656
+ }
657
+ LOG.info("Capture system verified successfully.");
658
+ return null;
659
+ }
660
+ catch (error) {
661
+ const errorMessage = formatError(error);
662
+ // Clean up the test page.
663
+ unregisterManagedPage(page);
664
+ if (!page.isClosed()) {
665
+ page.close().catch(() => { });
666
+ }
667
+ return errorMessage;
668
+ }
669
+ }
670
+ //# sourceMappingURL=setup.js.map