prismcast 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/dist/app.d.ts +88 -6
  2. package/dist/app.js +287 -154
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.d.ts +6 -17
  5. package/dist/browser/cdp.js +6 -57
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/index.d.ts +106 -23
  8. package/dist/browser/index.js +292 -225
  9. package/dist/browser/index.js.map +1 -1
  10. package/dist/browser/precaching.d.ts +25 -11
  11. package/dist/browser/precaching.js +245 -97
  12. package/dist/browser/precaching.js.map +1 -1
  13. package/dist/browser/tabCapture.js +3 -10
  14. package/dist/browser/tabCapture.js.map +1 -1
  15. package/dist/browser/tuning/comcastPolymer.js +13 -14
  16. package/dist/browser/tuning/comcastPolymer.js.map +1 -1
  17. package/dist/browser/tuning/hbo.js +34 -17
  18. package/dist/browser/tuning/hbo.js.map +1 -1
  19. package/dist/browser/video.d.ts +1 -1
  20. package/dist/browser/video.js +3 -3
  21. package/dist/channels/index.js +5 -5
  22. package/dist/channels/index.js.map +1 -1
  23. package/dist/cliHelp.d.ts +11 -0
  24. package/dist/cliHelp.js +148 -0
  25. package/dist/cliHelp.js.map +1 -0
  26. package/dist/config/channelForm.js +5 -4
  27. package/dist/config/channelForm.js.map +1 -1
  28. package/dist/config/consistencyProbe.d.ts +2 -4
  29. package/dist/config/consistencyProbe.js +8 -51
  30. package/dist/config/consistencyProbe.js.map +1 -1
  31. package/dist/config/index.d.ts +107 -55
  32. package/dist/config/index.js +499 -267
  33. package/dist/config/index.js.map +1 -1
  34. package/dist/config/paths.d.ts +13 -8
  35. package/dist/config/paths.js +21 -25
  36. package/dist/config/paths.js.map +1 -1
  37. package/dist/config/persistence.d.ts +57 -11
  38. package/dist/config/persistence.js +127 -44
  39. package/dist/config/persistence.js.map +1 -1
  40. package/dist/config/reactivity.d.ts +60 -36
  41. package/dist/config/reactivity.js +89 -116
  42. package/dist/config/reactivity.js.map +1 -1
  43. package/dist/config/services.d.ts +61 -26
  44. package/dist/config/services.js +137 -68
  45. package/dist/config/services.js.map +1 -1
  46. package/dist/config/streamSettings.d.ts +24 -0
  47. package/dist/config/streamSettings.js +17 -0
  48. package/dist/config/streamSettings.js.map +1 -0
  49. package/dist/config/userChannels.d.ts +60 -52
  50. package/dist/config/userChannels.js +138 -188
  51. package/dist/config/userChannels.js.map +1 -1
  52. package/dist/config/userConfig.d.ts +224 -61
  53. package/dist/config/userConfig.js +351 -134
  54. package/dist/config/userConfig.js.map +1 -1
  55. package/dist/hdhr/discover.d.ts +7 -1
  56. package/dist/hdhr/discover.js +12 -6
  57. package/dist/hdhr/discover.js.map +1 -1
  58. package/dist/hdhr/index.d.ts +51 -10
  59. package/dist/hdhr/index.js +108 -112
  60. package/dist/hdhr/index.js.map +1 -1
  61. package/dist/hdhr/udp.d.ts +11 -4
  62. package/dist/hdhr/udp.js +25 -20
  63. package/dist/hdhr/udp.js.map +1 -1
  64. package/dist/index.js +16 -143
  65. package/dist/index.js.map +1 -1
  66. package/dist/native/index.d.ts +1 -1
  67. package/dist/native/index.js +4 -3
  68. package/dist/native/index.js.map +1 -1
  69. package/dist/native/proxy.d.ts +1 -1
  70. package/dist/native/proxy.js +36 -28
  71. package/dist/native/proxy.js.map +1 -1
  72. package/dist/routes/cdp.d.ts +7 -6
  73. package/dist/routes/cdp.js +9 -8
  74. package/dist/routes/cdp.js.map +1 -1
  75. package/dist/routes/components.d.ts +23 -5
  76. package/dist/routes/components.js +21 -4
  77. package/dist/routes/components.js.map +1 -1
  78. package/dist/routes/config/channels/endpoints/browse.js +39 -29
  79. package/dist/routes/config/channels/endpoints/browse.js.map +1 -1
  80. package/dist/routes/config/channels/endpoints/bulk.js +2 -6
  81. package/dist/routes/config/channels/endpoints/bulk.js.map +1 -1
  82. package/dist/routes/config/channels/endpoints/predefined.js +5 -15
  83. package/dist/routes/config/channels/endpoints/predefined.js.map +1 -1
  84. package/dist/routes/config/channels/endpoints/prefs.js +7 -6
  85. package/dist/routes/config/channels/endpoints/prefs.js.map +1 -1
  86. package/dist/routes/config/channels/endpoints/service.js +6 -5
  87. package/dist/routes/config/channels/endpoints/service.js.map +1 -1
  88. package/dist/routes/config/channels/endpoints/tags.js +4 -16
  89. package/dist/routes/config/channels/endpoints/tags.js.map +1 -1
  90. package/dist/routes/config/channels/table.d.ts +2 -1
  91. package/dist/routes/config/channels/table.js +11 -13
  92. package/dist/routes/config/channels/table.js.map +1 -1
  93. package/dist/routes/config/http/envelope.d.ts +6 -3
  94. package/dist/routes/config/http/envelope.js +18 -5
  95. package/dist/routes/config/http/envelope.js.map +1 -1
  96. package/dist/routes/config/index.d.ts +21 -13
  97. package/dist/routes/config/index.js +47 -34
  98. package/dist/routes/config/index.js.map +1 -1
  99. package/dist/routes/config/settings.d.ts +43 -6
  100. package/dist/routes/config/settings.js +225 -125
  101. package/dist/routes/config/settings.js.map +1 -1
  102. package/dist/routes/config/vocabulary.d.ts +24 -0
  103. package/dist/routes/config/vocabulary.js +33 -0
  104. package/dist/routes/config/vocabulary.js.map +1 -0
  105. package/dist/routes/debug.js +28 -13
  106. package/dist/routes/debug.js.map +1 -1
  107. package/dist/routes/root/content.js +33 -26
  108. package/dist/routes/root/content.js.map +1 -1
  109. package/dist/routes/root/scripts/channels.js +23 -11
  110. package/dist/routes/root/scripts/channels.js.map +1 -1
  111. package/dist/routes/root/scripts/config.d.ts +1 -1
  112. package/dist/routes/root/scripts/config.js +127 -79
  113. package/dist/routes/root/scripts/config.js.map +1 -1
  114. package/dist/routes/root/scripts/shared.js +34 -23
  115. package/dist/routes/root/scripts/shared.js.map +1 -1
  116. package/dist/routes/root/scripts/status.handlers.d.ts +8 -13
  117. package/dist/routes/root/scripts/status.handlers.js +35 -21
  118. package/dist/routes/root/scripts/status.handlers.js.map +1 -1
  119. package/dist/routes/root/styles.js +13 -16
  120. package/dist/routes/root/styles.js.map +1 -1
  121. package/dist/routes/services.js +12 -16
  122. package/dist/routes/services.js.map +1 -1
  123. package/dist/routes/theme.js +12 -12
  124. package/dist/routes/theme.js.map +1 -1
  125. package/dist/routes/ui.d.ts +4 -3
  126. package/dist/routes/ui.js +17 -13
  127. package/dist/routes/ui.js.map +1 -1
  128. package/dist/service/generators.js +12 -9
  129. package/dist/service/generators.js.map +1 -1
  130. package/dist/streaming/captureSession.d.ts +4 -4
  131. package/dist/streaming/captureSession.js +6 -7
  132. package/dist/streaming/captureSession.js.map +1 -1
  133. package/dist/streaming/codec.d.ts +12 -2
  134. package/dist/streaming/codec.js +40 -28
  135. package/dist/streaming/codec.js.map +1 -1
  136. package/dist/streaming/fmp4Segmenter.d.ts +14 -4
  137. package/dist/streaming/fmp4Segmenter.js +99 -79
  138. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  139. package/dist/streaming/hls.d.ts +21 -14
  140. package/dist/streaming/hls.js +148 -122
  141. package/dist/streaming/hls.js.map +1 -1
  142. package/dist/streaming/hlsResume.d.ts +36 -13
  143. package/dist/streaming/hlsResume.js +68 -37
  144. package/dist/streaming/hlsResume.js.map +1 -1
  145. package/dist/streaming/monitor.d.ts +5 -3
  146. package/dist/streaming/monitor.js +47 -37
  147. package/dist/streaming/monitor.js.map +1 -1
  148. package/dist/streaming/mp4Parser.d.ts +29 -4
  149. package/dist/streaming/mp4Parser.js +49 -7
  150. package/dist/streaming/mp4Parser.js.map +1 -1
  151. package/dist/streaming/playlistBuilder.d.ts +1 -2
  152. package/dist/streaming/playlistBuilder.js +2 -3
  153. package/dist/streaming/playlistBuilder.js.map +1 -1
  154. package/dist/streaming/preroll.d.ts +43 -16
  155. package/dist/streaming/preroll.js +112 -62
  156. package/dist/streaming/preroll.js.map +1 -1
  157. package/dist/streaming/pretune.d.ts +2 -2
  158. package/dist/streaming/pretune.js +11 -7
  159. package/dist/streaming/pretune.js.map +1 -1
  160. package/dist/streaming/recovery.d.ts +29 -2
  161. package/dist/streaming/recovery.js +43 -4
  162. package/dist/streaming/recovery.js.map +1 -1
  163. package/dist/streaming/registry.d.ts +13 -10
  164. package/dist/streaming/registry.js +6 -5
  165. package/dist/streaming/registry.js.map +1 -1
  166. package/dist/streaming/setup.d.ts +29 -20
  167. package/dist/streaming/setup.js +181 -170
  168. package/dist/streaming/setup.js.map +1 -1
  169. package/dist/streaming/showInfo.d.ts +24 -12
  170. package/dist/streaming/showInfo.js +120 -113
  171. package/dist/streaming/showInfo.js.map +1 -1
  172. package/dist/streaming/statusEmitter.d.ts +15 -10
  173. package/dist/streaming/statusEmitter.js +24 -20
  174. package/dist/streaming/statusEmitter.js.map +1 -1
  175. package/dist/types/config.d.ts +24 -13
  176. package/dist/types/config.js +5 -1
  177. package/dist/types/config.js.map +1 -1
  178. package/dist/types/index.d.ts +3 -2
  179. package/dist/types/index.js +2 -1
  180. package/dist/types/index.js.map +1 -1
  181. package/dist/types/profiles.d.ts +2 -2
  182. package/dist/types/selection.d.ts +12 -10
  183. package/dist/types/streaming.d.ts +12 -5
  184. package/dist/types/streaming.js +12 -5
  185. package/dist/types/streaming.js.map +1 -1
  186. package/dist/utils/debugFilter.d.ts +3 -2
  187. package/dist/utils/debugFilter.js +7 -5
  188. package/dist/utils/debugFilter.js.map +1 -1
  189. package/dist/{hdhr → utils}/deviceId.d.ts +1 -1
  190. package/dist/{hdhr → utils}/deviceId.js +11 -29
  191. package/dist/utils/deviceId.js.map +1 -0
  192. package/dist/utils/errors.d.ts +7 -0
  193. package/dist/utils/errors.js +12 -3
  194. package/dist/utils/errors.js.map +1 -1
  195. package/dist/utils/fileLogger.d.ts +23 -12
  196. package/dist/utils/fileLogger.js +73 -41
  197. package/dist/utils/fileLogger.js.map +1 -1
  198. package/dist/utils/httpErrors.d.ts +13 -0
  199. package/dist/utils/httpErrors.js +22 -0
  200. package/dist/utils/httpErrors.js.map +1 -0
  201. package/dist/utils/index.d.ts +3 -0
  202. package/dist/utils/index.js +3 -0
  203. package/dist/utils/index.js.map +1 -1
  204. package/dist/utils/logger.d.ts +9 -8
  205. package/dist/utils/logger.js +64 -20
  206. package/dist/utils/logger.js.map +1 -1
  207. package/dist/utils/markup.js +8 -3
  208. package/dist/utils/markup.js.map +1 -1
  209. package/dist/utils/plainObject.d.ts +6 -0
  210. package/dist/utils/plainObject.js +18 -0
  211. package/dist/utils/plainObject.js.map +1 -0
  212. package/dist/utils/runtimeIdentity.context.js +1 -29
  213. package/dist/utils/runtimeIdentity.context.js.map +1 -1
  214. package/dist/utils/runtimeIdentity.d.ts +17 -6
  215. package/dist/utils/runtimeIdentity.js +50 -17
  216. package/dist/utils/runtimeIdentity.js.map +1 -1
  217. package/package.json +5 -5
  218. package/dist/hdhr/deviceId.js.map +0 -1
@@ -50,11 +50,11 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
50
50
  var e = new Error(message);
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
- import { BrowserCaptureImpairedError, BrowserSupersededError, BrowserUnavailableError, acquireCaptureStream, confirmSharedWindowPlacement, emulateCaptureSurface, emulateLayoutSurface, getBrowserInstance, getCaptureImpairment, getCurrentBrowser, installActivationHeal, noteBrowserCaptureImpaired, registerManagedPage, resolveSharedWindowCarrier, setCaptureProbe, syncWindowVisibility, unregisterManagedPage } from "../browser/index.js";
53
+ import { BrowserCaptureImpairedError, BrowserSupersededError, BrowserUnavailableError, CaptureLaunchError, acquireCaptureStream, confirmSharedWindowPlacement, emulateCaptureSurface, emulateLayoutSurface, getBrowserInstance, getCaptureImpairment, getCurrentBrowser, installActivationHeal, noteBrowserCaptureImpaired, reaffirmCaptureSurface, registerManagedPage, resolveSharedWindowCarrier, setCaptureProbe, syncWindowVisibility, unregisterManagedPage } from "../browser/index.js";
54
54
  import { CAPTURE_SOURCE_UNAVAILABLE_MESSAGE, isChannelSelectionProfile } from "../types/index.js";
55
55
  import { CaptureAbandonedError, CaptureTurnTimeoutError, createCaptureLock } from "./captureLock.js";
56
56
  import { FINALIZE_SETTLE_DELAY, installManifestInterceptor } from "../browser/manifestInterceptor.js";
57
- import { LOG, chromeFetch, delay, extractDomain, formatError, getStreamContext, maxRetryDuration, registerAbortController, resolveFFmpegPath, retryOperation, runWithStreamContext, spawnFFmpeg, startTimer, timeoutSignal, waitWithTimeout } from "../utils/index.js";
57
+ import { LOG, chromeFetch, extractDomain, formatError, getStreamContext, maxRetryDuration, registerAbortController, resolveFFmpegPath, retryOperation, runWithStreamContext, spawnFFmpeg, startTimer, timeoutSignal, toError, waitWithTimeout } from "../utils/index.js";
58
58
  import { getAuthDomainForChannel, getServiceDisplayName, resolveServiceKey } from "../config/services.js";
59
59
  import { getDomainConfig, getProfileForChannel, getProfileForUrl, resolveProfile } from "../config/profiles.js";
60
60
  import { getProviderByStrategy, invalidateDirectUrl, resolveDirectUrl } from "../browser/channelSelection.js";
@@ -65,17 +65,15 @@ import { getBuiltinProfile } from "../config/sites.js";
65
65
  import { getCachedEncryption } from "../native/probe.js";
66
66
  import { getCaptureMimeType } from "./codec.js";
67
67
  import { getDomainAuthState } from "../config/health.js";
68
- import { getNextStreamId } from "./registry.js";
69
68
  import { getUserProfiles } from "../config/userProfiles.js";
70
69
  import { isCaptureInfrastructureError } from "./recovery.js";
71
70
  import { monitorPlaybackHealth } from "./monitor.js";
72
71
  import { mutateChannels } from "../config/userChannels.js";
73
72
  import { openSharedWindowTab } from "../browser/tabSelection.js";
74
73
  import { pipeline } from "node:stream/promises";
75
- import { reaffirmCaptureSurface } from "../browser/cdp.js";
76
74
  import { startOverlayHandling } from "../browser/consent.js";
77
75
  import { systemClock } from "homebridge-plugin-utils";
78
- /* This module contains the common stream setup logic for HLS streaming. The core logic is split into two functions:
76
+ /* This module contains the common stream setup logic for HLS streaming. At its core are createPageWithCapture() and setupStream():
79
77
  *
80
78
  * 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
81
79
  * initial stream setup and tab replacement recovery use.
@@ -83,12 +81,16 @@ import { systemClock } from "homebridge-plugin-utils";
83
81
  * 2. setupStream(): Orchestrates stream creation by calling createPageWithCapture(), then starting the health monitor. This is the
84
82
  * entry point for new stream requests.
85
83
  *
86
- * The separation allows tab replacement recovery (in monitor.ts) to reuse the capture setup logic without duplicating code. When a browser tab becomes unresponsive,
87
- * the recovery handler can close the old tab, call createPageWithCapture() to create a fresh one, and continue with the same stream ID.
84
+ * Beside them, the module holds the shared channel playback establishment (establishChannelPlayback), native manifest re-establishment
85
+ * (reestablishChannelManifest), the capture-system verification (verifyCaptureSystem), and the passive mid-life capture detector.
86
+ *
87
+ * The separation allows tab replacement recovery, built in hls.ts and invoked by the monitor, to reuse the capture setup logic without duplicating code. When a
88
+ * browser tab becomes unresponsive, the recovery handler calls createPageWithCapture() to establish a fresh page while the existing capture keeps serving, then
89
+ * swaps to the new page under the same stream ID.
88
90
  *
89
91
  * createPageWithCapture() handles:
90
92
  * - Browser page creation with CSP bypass
91
- * - Media stream initialization (native fMP4 or Matroska+FFmpeg)
93
+ * - Media stream initialization (a Matroska capture that FFmpeg remuxes to fMP4)
92
94
  * - Navigation with retry
93
95
  * - Video element detection and playback setup
94
96
  *
@@ -97,8 +99,6 @@ import { systemClock } from "homebridge-plugin-utils";
97
99
  * - Health monitor startup
98
100
  * - Cleanup function creation
99
101
  */
100
- // Native fMP4 capture uses MP4/AAC for direct HLS segmentation without transcoding.
101
- const NATIVE_FMP4_MIME_TYPE = "video/mp4;codecs=avc1,mp4a.40.2";
102
102
  // Capture initialization is serialized through a task-scoped lock. Chrome's tabCapture extension can only initialize one capture at a time - a second start while
103
103
  // one is in flight fails with "Cannot capture a tab with an active stream" - so every acquisition runs as a task on this one process-wide lock, which holds the
104
104
  // turn until the task's promise settles. Once a capture is established it runs concurrently with other captures without issue.
@@ -129,9 +129,9 @@ const PROBE_TEARDOWN_ALLOWANCE_MS = STOP_RECORDING_CEILING_MS + PAGE_CLOSE_MARGI
129
129
  // that retry while still preventing a pathological hang if multiple internal timeouts chain sequentially. Consumed by both the phase-2 race and the interception
130
130
  // budget below.
131
131
  const PLAYBACK_INIT_TIMEOUT = 45000;
132
- // Margin folded into the interception budget beyond the phases it explicitly sizes: the small span between phase-2 finishing and finalize firing (the window
133
- // resize and minimize, setupStream's pre-verification work) plus true slack. The interception window is a leak bound for a tune that dies without unwinding, not a
134
- // latency bound - no healthy path waits on it - so its generosity costs nothing.
132
+ // Margin folded into the interception budget beyond the phases it explicitly sizes: the small span between phase-2 finishing and finalize firing (the
133
+ // capture-surface re-affirmation and the window-visibility sync, setupStream's pre-verification work) plus true slack. The interception window is a leak bound for
134
+ // a tune that dies without unwinding, not a latency bound - no healthy path waits on it - so its generosity costs nothing.
135
135
  const INTERCEPTION_BUDGET_MARGIN_MS = 5000;
136
136
  // The caller-visible capture-timeout messages, each defined once so the lock's deadline errors and any assertion test read the exact text. Both match
137
137
  // isCaptureInfrastructureError via its "timed out" substring, so they are exported for the classification assertion that locks that contract.
@@ -294,8 +294,8 @@ export function validateStreamUrl(url) {
294
294
  // Page and Capture Creation.
295
295
  /**
296
296
  * Disposes a browser page acquired during stream setup: unregisters it from managed-page tracking and closes it if still open. The close is fire-and-forget with a
297
- * debug log on error, matching the long-standing setup-failure cleanup behavior. Used as the DisposableStack disposer for pages in createPageWithCapture and
298
- * setupStream, and by the setup-result cleanup closure, so every setup-phase page teardown flows through one definition.
297
+ * debug log on error, because a page that fails to close during setup cleanup has no caller left to report to. Used as the DisposableStack disposer for pages in
298
+ * createPageWithCapture and setupStream, and by the setup-result cleanup closure, so every setup-phase page teardown flows through one definition.
299
299
  * @param page - The page to dispose.
300
300
  */
301
301
  function disposePage(page) {
@@ -307,18 +307,18 @@ function disposePage(page) {
307
307
  }
308
308
  }
309
309
  const defaultCreatePageWithCaptureDeps = { acquireCaptureStream, awaitCaptureVerdict: noteClassifiedCaptureFailure,
310
- emulateCaptureSurface, getCurrentBrowser, installActivationHeal, openSharedWindowTab, reaffirmCaptureSurface, spawnFFmpeg, startOverlayHandling,
311
- syncWindowVisibility };
310
+ emulateCaptureSurface, getCurrentBrowser, installActivationHeal, openSharedWindowTab, reaffirmCaptureSurface, resolveFFmpegPath, spawnFFmpeg,
311
+ startOverlayHandling, syncWindowVisibility };
312
312
  /* The window-topology answers the open primitive needs and cannot reach for itself: tabSelection.ts speaks to the capture extension alone, so the CDP-side carrier
313
- * resolution and placement confirmation arrive from here, where both modules are already in view. One record, referenced by both call sites, because the two call
314
- * sites want the identical pair.
313
+ * resolution and placement confirmation arrive from here, where both modules are already in view. One record, referenced by every call site, because every call
314
+ * site wants the identical pair.
315
315
  */
316
316
  const SHARED_WINDOW_TOPOLOGY = { confirmPlacement: confirmSharedWindowPlacement, resolveCarrier: resolveSharedWindowCarrier };
317
317
  /**
318
318
  * 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
319
319
  * recovery. It handles:
320
320
  * - Creating a new browser page with CSP bypass
321
- * - Initializing media capture (native fMP4 or Matroska+FFmpeg)
321
+ * - Initializing media capture (a Matroska capture that FFmpeg remuxes to fMP4)
322
322
  * - Navigating to the URL with retry
323
323
  * - Setting up video playback via navigateToPage() + initializePlayback()
324
324
  * - Re-entering itself once, on a fresh page through a freshly acquired browser, when Chrome refuses the capture start and the mid-life probe's verdict says a
@@ -328,19 +328,22 @@ const SHARED_WINDOW_TOPOLOGY = { confirmPlacement: confirmSharedWindowPlacement,
328
328
  * - Creating the segmenter and attaching it to the capture session via captureSession.attachSegmenter()
329
329
  * - Registering/updating the stream in the registry
330
330
  * - Starting/updating the health monitor
331
- * - Handling cleanup on failure
331
+ *
332
+ * On failure the function disposes everything it acquired, so the caller owns only what a successful return hands over.
332
333
  *
333
334
  * @param options - Options for page and capture creation.
334
- * @param deps - The injected browser and overlay-poll collaborators; defaults to defaultCreatePageWithCaptureDeps. Threaded so a test drives this function without a
335
- * live Chrome by substituting the shared-browser accessor, the capture acquisition, and the static-capture overlay poll.
336
- * @returns The page, context, and capture session (which owns the raw capture stream and any FFmpeg child).
337
- * @throws Error if page creation, capture initialization, or navigation fails.
335
+ * @param deps - The injected browser-boundary collaborators (see CreatePageWithCaptureDeps); defaults to defaultCreatePageWithCaptureDeps, so a test can drive this
336
+ * function without a live Chrome.
337
+ * @returns The page, context, and capture session (which owns the raw capture stream and its FFmpeg child).
338
+ * @throws DirectUrlEstablishmentError when a resolved direct watch URL failed on evidence against it (the hint is already evicted); the browser supervisor's own
339
+ * refusal errors from the browser accessor unchanged; a plain Error when Chrome crashed during capture initialization more times than the retry cap allows;
340
+ * otherwise the page-creation, capture-initialization, or navigation failure as it was raised.
338
341
  */
339
342
  export async function createPageWithCapture(options, deps = defaultCreatePageWithCaptureDeps) {
340
343
  const env_1 = { stack: [], error: void 0, hasError: false };
341
344
  try {
342
345
  const captureElapsed = startTimer();
343
- const { comment, onFFmpegError, profile, streamId, url } = options;
346
+ const { comment, onFFmpegError, profile, settings, streamId, url } = options;
344
347
  /* Bring the window on screen before anything else. Capture reads the compositor's output for the shared window, and that output is only composed correctly for a
345
348
  * window the desktop is presenting, so the sync has to land ahead of capture acquisition rather than alongside it. The pending registry entry is already in
346
349
  * capture mode by the time any caller reaches here, on both request paths, so the policy reads capture-active and this resolves to a window on screen.
@@ -349,7 +352,7 @@ export async function createPageWithCapture(options, deps = defaultCreatePageWit
349
352
  // Acquire every resource on a DisposableStack so that any throw - in capture initialization, navigation, or playback setup - disposes them structurally as the
350
353
  // function unwinds, in last-acquired-first order (capture session before page, so the capture stream is destroyed and STOP_RECORDING fires while the browser is
351
354
  // still connected, before the page closes). On success we move() the stack to disarm it and transfer ownership to the caller. This centralizes teardown that
352
- // would otherwise be repeated in each failure path, and closes the navigation-path leak of the manifest interceptor.
355
+ // would otherwise be repeated in each failure path, and keeps the manifest interceptor inside the same teardown on every exit.
353
356
  const resources = __addDisposableResource(env_1, new DisposableStack(), false);
354
357
  /* Create browser page. The establishment goes on to start a capture, so a browser that can no longer start one refuses here, before a page exists. The page is
355
358
  * opened as a tab of the shared window rather than wherever Chrome would place it, because a capture tab rooted in a discovery window keeps that window alive
@@ -373,40 +376,38 @@ export async function createPageWithCapture(options, deps = defaultCreatePageWit
373
376
  // Install the tab-activation heal alongside it, before capture is acquired, so the page is enrolled and carries its listener from its first document onward:
374
377
  // whichever way this tab becomes the selected one, the capture's composition moves back to the emulated surface about a second later.
375
378
  await deps.installActivationHeal(page);
376
- // Select MIME type based on capture mode. FFmpeg mode is more stable for long recordings because Chrome's native fMP4 MediaRecorder can become unstable. The
377
- // codec decision (H.264 vs HEVC) is delegated to the codec module, which considers the user's allowlist and GPU hardware capabilities.
378
- const useFFmpeg = CONFIG.streaming.captureMode === "ffmpeg";
379
- const captureMimeType = useFFmpeg ? getCaptureMimeType() : NATIVE_FMP4_MIME_TYPE;
379
+ // The capture's Matroska MIME type carries the codec decision, which is the codec module's: it weighs the user's allowlist against the GPU's hardware encoders.
380
+ const captureMimeType = getCaptureMimeType();
380
381
  // Resolve the FFmpeg binary path up front, before the raw capture stream is acquired below. resolveFFmpegPath is a memoized resolver that can sticky-reject; if its
381
382
  // await sat between acquiring the raw capture stream and wrapping it in a CaptureSession, a rejection would strand the stream undestroyed (STOP_RECORDING never
382
383
  // fires, leaving chrome.tabCapture active). Resolving it here keeps any rejection on a path with no capture resource yet acquired, so the CaptureSession remains
383
- // the single owner from the instant the stream exists. Falls back to "ffmpeg" so spawn() defers to PATH lookup; only meaningful in FFmpeg mode.
384
- const ffmpegBin = useFFmpeg ? ((await resolveFFmpegPath()) ?? "ffmpeg") : "ffmpeg";
385
- // The capture-pipeline composite, assigned once the raw capture stream and optional FFmpeg child exist and registered on the DisposableStack the moment it is built.
384
+ // the single owner from the instant the stream exists. Falls back to "ffmpeg" so spawn() defers to PATH lookup.
385
+ const ffmpegBin = (await deps.resolveFFmpegPath()) ?? "ffmpeg";
386
+ // The capture-pipeline composite, assigned once the raw capture stream and its FFmpeg child exist and registered on the DisposableStack the moment it is built.
386
387
  let captureSession;
387
388
  // Initialize media stream capture. The whole capture-init phase runs inside one try so a browser crash detected at turn grant drives the closed-page recursion,
388
389
  // while every other failure unwinds through the DisposableStack.
389
390
  try {
390
391
  const streamOptions = {
391
392
  audio: true,
392
- audioBitsPerSecond: CONFIG.streaming.audioBitsPerSecond,
393
+ audioBitsPerSecond: settings.audioBitsPerSecond,
393
394
  mimeType: captureMimeType,
394
395
  video: true,
395
- videoBitsPerSecond: CONFIG.streaming.videoBitsPerSecond,
396
+ videoBitsPerSecond: settings.videoBitsPerSecond,
396
397
  /* The dimension bounds hold the track to exactly the surface emulateCaptureSurface declared on this page, rather than to a second read of the configured
397
398
  * preset, so a preset saved mid-establishment cannot leave the encoder constrained to dimensions the page was never emulated at.
398
399
  *
399
- * The frame rate is constrained to a 30-60 fps band: 60 is the live-TV ceiling, and a 30 floor keeps motion smooth even when the user configures a lower
400
- * rate. The ceiling is fixed at 60 while the floor follows the user's configured rate (clamped into the band), so the encoder favours the requested rate but
401
- * never drops below 30. The readiness probe (attemptCaptureProbe) instead holds both bounds to a flat 30 because its acquisition fails or succeeds at the
402
- * tabCapture API level before encoding matters, so a representative-but-minimal constraint set suffices there.
400
+ * Both frame-rate bounds hold the stream's frame rate, so the track is held to exactly the rate the stream started with, on its first capture and on every
401
+ * tab replacement alike. The configuration load keeps that rate inside the range the setting's metadata declares, 60 being the live-TV ceiling and 30 the
402
+ * floor below which motion judders. The readiness probe (attemptCaptureProbe) instead holds both bounds to a flat 30 because its acquisition fails or
403
+ * succeeds at the tabCapture API level before encoding matters, so a representative-but-minimal constraint set suffices there.
403
404
  */
404
405
  videoConstraints: {
405
406
  mandatory: {
406
- maxFrameRate: 60,
407
+ maxFrameRate: settings.frameRate,
407
408
  maxHeight: surface.height,
408
409
  maxWidth: surface.width,
409
- minFrameRate: Math.max(30, Math.min(60, CONFIG.streaming.frameRate)),
410
+ minFrameRate: settings.frameRate,
410
411
  minHeight: surface.height,
411
412
  minWidth: surface.width
412
413
  }
@@ -444,61 +445,57 @@ export async function createPageWithCapture(options, deps = defaultCreatePageWit
444
445
  },
445
446
  turnWaitMs: CONFIG.streaming.navigationTimeout
446
447
  });
447
- // For FFmpeg mode, spawn FFmpeg to transcode the Matroska stream to fMP4. FFmpeg copies the H264 video and transcodes Opus audio to AAC. The binary path was
448
- // resolved up front (above) so no throwable await sits between acquiring the raw capture stream and wrapping it in the CaptureSession that owns it. The spawn and
449
- // pipeline wiring below are synchronous, so the stream is owned the instant it exists.
450
- let ffmpegProcess = null;
451
- if (useFFmpeg) {
452
- const ffmpeg = deps.spawnFFmpeg(ffmpegBin, CONFIG.streaming.audioBitsPerSecond, (error) => {
453
- LOG.error("FFmpeg process error: %s.", formatError(error));
454
- if (onFFmpegError) {
455
- onFFmpegError(error);
456
- }
457
- }, streamId, comment);
458
- ffmpegProcess = ffmpeg;
459
- /* A disposed pipeline must never fire its error callback. The two listeners below sit on the process's own streams rather than inside the spawn wrapper,
460
- * so they are the two places a post-kill teardown event can still reach a caller, and each gates on the same teardown flag the wrapper's own handlers use.
461
- *
462
- * The gate is what keeps a tab replacement's discard silent. A replacement disposes the outgoing pipeline in the same synchronous frame it installs the
463
- * incoming one, so a stray event from the killed FFmpeg lands a tick later - against a registry that already holds a healthy new pipeline - and this
464
- * closure's callback would break the circuit on the very stream the replacement just saved. String filters cannot cover that: they name the errors
465
- * teardown is known to produce, and the point here is every error a killed pipeline produces.
466
- */
467
- const isTornDown = () => ffmpeg.isShuttingDown();
468
- // Handle pipe errors on stdout. Stdin errors are handled by pipeline() below.
469
- ffmpeg.stdout.on("error", (error) => {
470
- const errorMessage = formatError(error);
471
- if (errorMessage.includes("EPIPE") || isTornDown()) {
472
- LOG.debug("streaming:ffmpeg", "FFmpeg stdout pipe closed: %s.", errorMessage);
473
- }
474
- else {
475
- LOG.error("FFmpeg stdout pipe error: %s.", errorMessage);
476
- ffmpeg.kill();
477
- if (onFFmpegError) {
478
- onFFmpegError(error);
479
- }
480
- }
481
- });
482
- // Pipe the Matroska capture stream to FFmpeg's stdin using pipeline() for proper cleanup. When FFmpeg is killed during tab replacement, pipeline() automatically
483
- // destroys the source stream, preventing "write after end" errors that would occur with .pipe().
484
- pipeline(stream, ffmpeg.stdin).catch((error) => {
485
- const errorMessage = formatError(error);
486
- // EPIPE, "write after end", and "Premature close" errors are expected during cleanup when FFmpeg is killed or the capture stream is destroyed, and a
487
- // pipeline whose FFmpeg is being torn down produces whatever it produces.
488
- if (errorMessage.includes("EPIPE") || errorMessage.includes("write after end") || errorMessage.includes("Premature close") || isTornDown()) {
489
- return;
490
- }
491
- // Unexpected pipeline errors require cleanup.
492
- LOG.error("Capture pipeline error: %s.", errorMessage);
448
+ // Spawn FFmpeg to remux the Matroska capture to fMP4: it copies the captured video and transcodes the Opus audio to AAC. The binary path was resolved up front
449
+ // (above) so no throwable await sits between acquiring the raw capture stream and wrapping it in the CaptureSession that owns it. The spawn and pipeline wiring
450
+ // below are synchronous, so the stream is owned the instant it exists.
451
+ const ffmpeg = deps.spawnFFmpeg(ffmpegBin, settings.audioBitsPerSecond, (error) => {
452
+ LOG.error("FFmpeg process error: %s.", formatError(error));
453
+ if (onFFmpegError) {
454
+ onFFmpegError(error);
455
+ }
456
+ }, streamId, comment);
457
+ /* A disposed pipeline must never fire its error callback. The listeners below sit on the process's own streams rather than inside the spawn wrapper, so
458
+ * they are the places a post-kill teardown event can still reach a caller, and each gates on the same teardown flag the wrapper's own handlers use.
459
+ *
460
+ * The gate is what keeps a tab replacement's discard silent. A replacement disposes the outgoing pipeline in the same synchronous frame it installs the
461
+ * incoming one, so a stray event from the killed FFmpeg lands a tick later - against a registry that already holds a healthy new pipeline - and this
462
+ * closure's callback would break the circuit on the very stream the replacement just saved. String filters cannot cover that: they name the errors
463
+ * teardown is known to produce, and the point here is every error a killed pipeline produces.
464
+ */
465
+ const isTornDown = () => ffmpeg.isShuttingDown();
466
+ // Handle pipe errors on stdout. Stdin errors are handled by pipeline() below.
467
+ ffmpeg.stdout.on("error", (error) => {
468
+ const errorMessage = formatError(error);
469
+ if (errorMessage.includes("EPIPE") || isTornDown()) {
470
+ LOG.debug("streaming:ffmpeg", "FFmpeg stdout pipe closed: %s.", errorMessage);
471
+ }
472
+ else {
473
+ LOG.error("FFmpeg stdout pipe error: %s.", errorMessage);
493
474
  ffmpeg.kill();
494
475
  if (onFFmpegError) {
495
- onFFmpegError(error instanceof Error ? error : new Error(String(error)));
476
+ onFFmpegError(error);
496
477
  }
497
- });
498
- }
499
- // Wrap the raw capture stream and optional FFmpeg child as one self-disposing pipeline unit, and register it for structural teardown. The session derives the
500
- // segmenter input internally (FFmpeg's stdout in FFmpeg mode, the raw stream in native-fMP4 mode); the caller attaches the segmenter once it is created.
501
- captureSession = createCaptureSession({ ffmpegProcess, rawCaptureStream: stream });
478
+ }
479
+ });
480
+ // Pipe the Matroska capture stream to FFmpeg's stdin using pipeline() for proper cleanup. When FFmpeg is killed during tab replacement, pipeline() automatically
481
+ // destroys the source stream, preventing "write after end" errors that would occur with .pipe().
482
+ pipeline(stream, ffmpeg.stdin).catch((error) => {
483
+ const errorMessage = formatError(error);
484
+ // EPIPE, "write after end", and "Premature close" errors are expected during cleanup when FFmpeg is killed or the capture stream is destroyed, and a
485
+ // pipeline whose FFmpeg is being torn down produces whatever it produces.
486
+ if (errorMessage.includes("EPIPE") || errorMessage.includes("write after end") || errorMessage.includes("Premature close") || isTornDown()) {
487
+ return;
488
+ }
489
+ // Unexpected pipeline errors require cleanup.
490
+ LOG.error("Capture pipeline error: %s.", errorMessage);
491
+ ffmpeg.kill();
492
+ if (onFFmpegError) {
493
+ onFFmpegError(toError(error));
494
+ }
495
+ });
496
+ // Wrap the raw capture stream and its FFmpeg child as one self-disposing pipeline unit, and register it for structural teardown. The session feeds the segmenter
497
+ // from FFmpeg's fMP4 stdout; the caller attaches the segmenter once it is created.
498
+ captureSession = createCaptureSession({ ffmpegProcess: ffmpeg, rawCaptureStream: stream });
502
499
  resources.use(captureSession);
503
500
  }
504
501
  catch (error) {
@@ -540,7 +537,7 @@ export async function createPageWithCapture(options, deps = defaultCreatePageWit
540
537
  }
541
538
  // Every other rejection - a caller-deadline CaptureDeadlineError, a refusal the one retry has already been spent on, or any other capture-init failure - just
542
539
  // unwinds. The verdict still gets the failure, because a browser that cannot start captures is worth marking whoever asked; nothing waits on it here.
543
- // Resource teardown (page, interceptor, and the capture session once built) is handled by the DisposableStack as this throw unwinds the function scope.
540
+ // Resource teardown (the page, and the capture session once built) is handled by the DisposableStack as this throw unwinds the function scope.
544
541
  void deps.awaitCaptureVerdict(error);
545
542
  throw error;
546
543
  }
@@ -583,8 +580,8 @@ export async function createPageWithCapture(options, deps = defaultCreatePageWit
583
580
  */
584
581
  const tuneResult = await establishChannelPlayback(page, profile, manifestInterception, {
585
582
  initOptions: { persistResolution: options.persistResolution, requestedUrl: navigationUrl, skipChannelSelection: usedDirectUrl },
586
- // Navigate with retry. The 10-second navigationTimeout is appropriate for page loads, and retryOperation correctly reloads the page on genuine navigation
587
- // failures. The retry ladder is this path's own policy, kept separate from channel selection so its timeout does not race the internal click retry loops
583
+ // Navigate with retry. The configured navigationTimeout bounds each page load, and retryOperation reloads the page on genuine navigation failures. The
584
+ // retry ladder is this path's own policy, kept separate from channel selection so its timeout does not race the internal click retry loops
588
585
  // in channel selection strategies (guideGrid can take 15-20 seconds for binary search + click retries).
589
586
  navigate: async () => {
590
587
  await retryOperation({
@@ -771,7 +768,8 @@ const defaultEstablishChannelPlaybackDeps = { initializePlayback };
771
768
  * only per-path policy differs.
772
769
  * @param page - The page being established.
773
770
  * @param profile - The resolved site profile whose strategy drives navigation and channel selection.
774
- * @param handle - The interceptor observing this establishment, or null when nothing is observing it (a tab replacement, or an install that failed).
771
+ * @param handle - The interceptor observing this establishment, or null when nothing is observing it (a tab replacement, a skipped interception for a
772
+ * capture-locked or DRM-cached channel, or an install that failed).
775
773
  * @param options - The caller's navigation policy, its initialization options, and an optional settlement hook. See EstablishChannelPlaybackOptions.
776
774
  * @param deps - Injected browser-boundary collaborator; defaults to the real playback initializer in production.
777
775
  * @returns The result playback initialization produced: the video context for subsequent monitoring, and the strategy's direct-tune flag where it resolved one.
@@ -820,20 +818,17 @@ export async function adjudicateChannelSelection(handle, profile, directTune) {
820
818
  *
821
819
  * @param options - Stream configuration options.
822
820
  * @param onCircuitBreak - Callback invoked when the circuit breaker trips (stream unrecoverable).
823
- * @param deps - The injected browser and overlay-poll collaborators, forwarded to every establishment attempt this function makes; defaults to
824
- * defaultCreatePageWithCaptureDeps. Threaded for the same reason createPageWithCapture takes them: a test drives the establishment sequence - including its guide
825
- * fallback - without a live Chrome by substituting the shared-browser accessor, the capture launcher, and the overlay poll.
821
+ * @param deps - The injected browser-boundary collaborators (see CreatePageWithCaptureDeps), forwarded to every establishment attempt this function makes and
822
+ * read directly by its cleanup closure; defaults to defaultCreatePageWithCaptureDeps. Threaded for the same reason createPageWithCapture takes them: a test drives
823
+ * the establishment sequence - including its guide fallback - without a live Chrome.
826
824
  * @returns Setup result with capture session, cleanup function, and metadata.
827
825
  * @throws StreamSetupError if setup fails with appropriate status code and message.
828
826
  */
829
827
  export async function setupStream(options, onCircuitBreak, deps = defaultCreatePageWithCaptureDeps) {
830
- const { channel, channelName, channelSelector, clickSelector, clickToPlay, onTabReplacementFactory, probeIdentity, profileOverride, staticCapture, url } = options;
831
- // Use pre-allocated IDs from a pending registry entry when available, or generate new ones. Pre-allocated IDs ensure the abort controller, health monitor, and
832
- // tab replacement handler all reference the same stream identity as the pending entry in the registry.
833
- const streamId = options.streamId ?? generateStreamId(channelName, url);
834
- const numericStreamId = options.numericStreamId ?? getNextStreamId();
835
- // Stream setup is a composition point - it builds the capture lock, the health monitor, and the page - so it reads the system clock at its own boundary.
836
- const startTime = systemClock.now();
828
+ const { channel, channelName, channelSelector, clickSelector, clickToPlay, numericStreamId, onTabReplacementFactory, probeIdentity, profileOverride, settings, staticCapture, streamId, url } = options;
829
+ // The stream's start instant is its registry entry's, taken at registration, so the health monitor's status and the tune-time line report the one instant the
830
+ // registry holds.
831
+ const { startTime } = options;
837
832
  // Create and register the AbortController for this stream. This allows pending evaluate calls to be cancelled immediately when the stream is terminated.
838
833
  const abortController = new AbortController();
839
834
  registerAbortController(streamId, abortController);
@@ -916,8 +911,8 @@ export async function setupStream(options, onCircuitBreak, deps = defaultCreateP
916
911
  try {
917
912
  /* Skip CDP manifest interception when the channel is locked to screen capture, or when the probe cache already knows this stream's binding resolves to
918
913
  * DRM. The per-channel override short-circuits first, so a forced channel never installs the interceptor: nothing intercepts a manifest, no native
919
- * attempt runs, no probe fires, and the encryption cache stays untouched by that stream. The cache half avoids creating a CDP session that sits idle for
920
- * 15 seconds before the interceptor timeout cleans it up; every stream carries an identity, ad-hoc URLs included, so that lookup needs no guard.
914
+ * attempt runs, no probe fires, and the encryption cache stays untouched by that stream. The cache half avoids creating a CDP observer that can produce
915
+ * nothing a DRM stream could use; every stream carries an identity, ad-hoc URLs included, so that lookup needs no guard.
921
916
  */
922
917
  const skipInterception = (channel?.forceCapture === true) || (getCachedEncryption(probeIdentity, systemClock.now()) === "drm");
923
918
  // Build the persistResolution closure for the active channel. When the resolution layer in selectChannel() converts a category selector to a concrete call
@@ -932,6 +927,7 @@ export async function setupStream(options, onCircuitBreak, deps = defaultCreateP
932
927
  onFFmpegError: onCircuitBreak,
933
928
  persistResolution,
934
929
  profile,
930
+ settings,
935
931
  skipManifestInterception: skipInterception,
936
932
  streamId,
937
933
  url
@@ -977,7 +973,8 @@ export async function setupStream(options, onCircuitBreak, deps = defaultCreateP
977
973
  throw new StreamSetupError("Browser temporarily unavailable.", 503, "The browser can no longer start captures and will relaunch as soon as nothing is " +
978
974
  "using it. Please retry shortly.", { cause: error });
979
975
  }
980
- // createPageWithCapture handles its own cleanup on failure (closes page, kills FFmpeg).
976
+ // createPageWithCapture disposes everything it acquired on failure (the capture session with its FFmpeg child, the interceptor, and the page), so nothing is
977
+ // left here to clean up.
981
978
  const errorMessage = formatError(error);
982
979
  const lowerMessage = errorMessage.toLowerCase();
983
980
  const benignPatterns = ["abort", "session closed"];
@@ -985,14 +982,16 @@ export async function setupStream(options, onCircuitBreak, deps = defaultCreateP
985
982
  if (!isBenign) {
986
983
  LOG.error("Stream setup failed for %s: %s.", url, errorMessage);
987
984
  }
988
- /* Capture infrastructure errors should return 503 to signal Channels DVR to back off. These include Chrome capture state issues, capture-lock turn-wait
989
- * timeouts, and stream initialization failures. Using 503 with Retry-After prevents retry storms when there's a systemic issue. isCaptureInfrastructureError
990
- * (recovery.ts) is the single source of truth for this judgment, shared with the browser supervisor's readiness detection.
985
+ /* Capture infrastructure errors should return 503 to signal Channels DVR to back off. These include a browser whose capture system failed a launch gate,
986
+ * Chrome capture state issues, capture-lock turn-wait timeouts, and stream initialization failures. Using 503 with Retry-After prevents retry storms when
987
+ * there's a systemic issue. A launch-gate failure is answered by its type, a CaptureLaunchError, before the classifier reads any message, so the gate's
988
+ * messages are free to state what happened. isCaptureInfrastructureError (recovery.ts) is the single source of truth for every other failure, shared with the
989
+ * browser supervisor's readiness detection.
991
990
  *
992
991
  * The read here is purely for the client-facing status. The side effect that judgment also drives - handing the failure to the passive mid-life capture
993
992
  * detector - fires inside createPageWithCapture's own catches, where every caller of the acquisition reaches it rather than only this one.
994
993
  */
995
- const isCaptureError = isCaptureInfrastructureError(errorMessage);
994
+ const isCaptureError = (error instanceof CaptureLaunchError) || isCaptureInfrastructureError(errorMessage);
996
995
  // A failed tune on a service currently marked needs-sign-in most likely failed AT the auth wall, so the user-facing message leads with the remedy.
997
996
  throw new StreamSetupError("Stream error.", isCaptureError ? 503 : 500, withSignInGuidance("Failed to start stream.", channelName, serviceName), { cause: error });
998
997
  }
@@ -1011,10 +1010,10 @@ export async function setupStream(options, onCircuitBreak, deps = defaultCreateP
1011
1010
  // Tune verification. The shared adjudication stage finalizes the manifest interceptor and confirms the captured master manifest URL belongs to the channel
1012
1011
  // that was just tuned. This step makes setupStream "verified by construction" - every consumer of StreamSetupResult (HLS preroll, HLS blocking, MPEG-TS,
1013
1012
  // native proxy, capture mode) receives a stream guaranteed to be on the requested channel without having to opt in or coordinate. Streams with no manifest
1014
- // interception at all (DRM-cached channels, tab replacements) have nothing to adjudicate and skip the step entirely; verifyManifestSelection owns which of
1015
- // the remaining ones it can speak to. A failure reason throws StreamSetupError so the existing failure path marks channel health, terminates the pending
1016
- // registry entry, and surfaces a clear error - never silently delivers the wrong channel. The scope guard disposes the capture session, interceptor, and page
1017
- // as the throw unwinds.
1013
+ // interception at all (a channel locked to screen capture, a DRM-cached binding, or an install that failed) have nothing to adjudicate and skip the step
1014
+ // entirely; verifyManifestSelection owns which of the remaining ones it can speak to. A failure reason throws StreamSetupError so the existing failure path
1015
+ // marks channel health, terminates the pending registry entry, and surfaces a clear error - never silently delivers the wrong channel. The scope guard disposes
1016
+ // the capture session, interceptor, and page as the throw unwinds.
1018
1017
  if (manifestInterception) {
1019
1018
  const verifyError = await adjudicateChannelSelection(manifestInterception, profile, directTune);
1020
1019
  if (verifyError) {
@@ -1022,12 +1021,13 @@ export async function setupStream(options, onCircuitBreak, deps = defaultCreateP
1022
1021
  throw new StreamSetupError("Tune verification failed: " + verifyError, 502, withSignInGuidance("Tune verification failed for " + failureLabel + ". " + verifyError, channelName, serviceName));
1023
1022
  }
1024
1023
  }
1025
- // Monitor stream info for status updates. The serviceTag enables service-specific monitoring flags (e.g., tinySegmentThreshold).
1024
+ // Monitor stream info for status updates. The serviceTag enables service-specific monitoring flags (e.g., tinySegmentEvidenceSeconds).
1026
1025
  const monitorStreamInfo = {
1027
1026
  channelName: channel?.name ?? null,
1028
1027
  numericStreamId,
1029
1028
  serviceName,
1030
1029
  serviceTag: getDomainConfig(url)?.serviceTag,
1030
+ settings,
1031
1031
  startTime
1032
1032
  };
1033
1033
  // Start the health monitor for this stream.
@@ -1049,7 +1049,7 @@ export async function setupStream(options, onCircuitBreak, deps = defaultCreateP
1049
1049
  await deps.syncWindowVisibility();
1050
1050
  };
1051
1051
  // Success: transfer ownership out of the scope guard. The cleanup closure and, once the session is installed on the registry entry, terminateStream become
1052
- // responsible for disposing the page and capture session; the interceptor continues into tune verification and native streaming.
1052
+ // responsible for disposing the page and capture session; the interceptor, already finalized and verified above, continues into the native upgrade.
1053
1053
  owned.move();
1054
1054
  // Return the setup result.
1055
1055
  return {
@@ -1065,7 +1065,6 @@ export async function setupStream(options, onCircuitBreak, deps = defaultCreateP
1065
1065
  profile,
1066
1066
  profileName,
1067
1067
  serviceName,
1068
- startTime,
1069
1068
  streamId,
1070
1069
  url
1071
1070
  };
@@ -1181,15 +1180,17 @@ export async function reestablishChannelManifest(options) {
1181
1180
  * times with a delay between attempts, giving the system time to settle before giving up. At boot this prevents a rapid restart storm where the service manager
1182
1181
  * relaunches PrismCast repeatedly, each attempt orphaning a Chrome process; at relaunch it provides in-launch settling before the supervisor counts a launch failure.
1183
1182
  * @param browser - The Chrome instance to verify (the local instance being launched, passed in rather than re-acquired to avoid re-entering the launch in flight).
1183
+ * @param clock - The clock the wait between attempts and each attempt's own timing run on. Defaults to the system clock.
1184
+ * @throws CaptureLaunchError when every attempt failed.
1184
1185
  */
1185
- export async function verifyCaptureSystem(browser) {
1186
+ export async function verifyCaptureSystem(browser, clock = systemClock) {
1186
1187
  const PROBE_MAX_ATTEMPTS = 3;
1187
1188
  const PROBE_RETRY_DELAY = 5000;
1188
1189
  for (let attempt = 1; attempt <= PROBE_MAX_ATTEMPTS; attempt++) {
1189
1190
  // The launch gate runs its capture probe OFF the capture lock, deliberately: it fires pre-publish, when supervisor.current() is null, so a wedged gate task would
1190
1191
  // have no recovery target and would jam the shared lock. GATE mode keeps the acquisition bounded internally instead.
1191
1192
  // eslint-disable-next-line no-await-in-loop -- Sequential retries are intentional; each probe must complete before deciding whether to retry.
1192
- const result = await attemptCaptureProbe(browser, { boundMs: CAPTURE_PROBE_TIMEOUT_MS, kind: "gate" });
1193
+ const result = await attemptCaptureProbe(browser, { boundMs: CAPTURE_PROBE_TIMEOUT_MS, kind: "gate" }, clock);
1193
1194
  // Probe succeeded.
1194
1195
  if (result === null) {
1195
1196
  return;
@@ -1198,57 +1199,62 @@ export async function verifyCaptureSystem(browser) {
1198
1199
  if (attempt < PROBE_MAX_ATTEMPTS) {
1199
1200
  LOG.warn("Capture probe attempt %d of %d failed: %s. Retrying in %ds.", attempt, PROBE_MAX_ATTEMPTS, result, PROBE_RETRY_DELAY / 1000);
1200
1201
  // eslint-disable-next-line no-await-in-loop -- Deliberate delay between sequential retry attempts.
1201
- await delay(PROBE_RETRY_DELAY);
1202
+ await clock.delay(PROBE_RETRY_DELAY);
1202
1203
  }
1203
1204
  else {
1204
1205
  /* On Windows the probe's own error rarely names the cause, and two conditions account for nearly every failure there: a virtualization layer sitting between
1205
1206
  * Chrome and the display, and a profile that will not load an unpacked extension. Naming both in the thrown message saves the user a support round trip.
1206
- * The message is unchanged on every other platform, and the hint is appended after the probe's own text so the "timed out" substring the
1207
- * capture-infrastructure classifier reads is still present.
1207
+ * The message is unchanged on every other platform, and the hint follows the probe's own text so the cause the probe named still leads. The failure's
1208
+ * status comes from its type rather than from any wording here, so the hint is free to read as plain advice.
1208
1209
  */
1209
1210
  const windowsHint = (process.platform === "win32") ? " On Windows, Hyper-V or WSL can interfere with Chrome's capture pipeline, and Chrome refuses to " +
1210
1211
  "load the capture extension unless the profile has extension developer mode enabled." : "";
1211
1212
  // The probe's message is a diagnostic fragment that may or may not end in a period, so we terminate it before appending a sentence of our own.
1212
1213
  const detail = ((windowsHint.length > 0) && !result.endsWith(".")) ? (result + ".") : result;
1213
- throw new Error("Capture system verification failed after " + String(PROBE_MAX_ATTEMPTS) + " attempts: " + detail + windowsHint);
1214
+ throw new CaptureLaunchError("Capture system verification failed after " + String(PROBE_MAX_ATTEMPTS) + " attempts: " + detail + windowsHint);
1214
1215
  }
1215
1216
  }
1216
1217
  }
1217
1218
  /**
1218
- * Executes a single capture probe attempt. Creates a temporary page on the given browser, tries to start a capture stream, and tears everything down cleanly. It
1219
- * NEVER throws in either mode: it returns null on success or an error-message string on failure, so callers branch on the string. The two modes differ only in how
1220
- * the acquisition is bounded - see CaptureProbeMode.
1219
+ * Executes a single capture probe attempt. Creates a temporary page on the given browser, tries to start a capture stream, and tears everything down cleanly. A
1220
+ * failed attempt never throws in any mode: it returns null on success or an error-message string on failure, so callers branch on the string. Opening the page
1221
+ * and emulating its layout surface run inside the same try as the acquisition, so a rejection from either is reported the same way, releasing the page when one
1222
+ * was opened, and the gate's retry loop goes on to its next attempt. The modes differ only in how the acquisition is bounded - see CaptureProbeMode.
1221
1223
  * @param browser - The Chrome instance to probe.
1222
1224
  * @param mode - The operating mode: gate (internal acquisition race) or midlife (self-timed acquisition on the lock).
1223
1225
  * @param clock - Clock used for the mid-life self-timing and the teardown confirmation. Defaults to the system clock.
1224
- * @returns Null on success, or an error message string on failure.
1226
+ * @returns Null on success, or an error message string on a failed attempt.
1225
1227
  */
1226
1228
  async function attemptCaptureProbe(browser, mode, clock = systemClock) {
1227
- /* The mid-life probe runs against a browser that is serving streams, so its page is opened as a tab of the shared window for the reason a stream's page is: a
1228
- * probe tab rooted in a discovery window would hold that window open for as long as the probe took. The launch gate keeps the plain create: it runs pre-publish,
1229
- * when the browser has exactly one window and no discovery window can exist yet, so its tab has no wrong window to land in - and a gate that has to stay bounded
1230
- * gains nothing from queuing behind the selection executor to prove it.
1231
- */
1232
- const page = (mode.kind === "midlife") ? await openSharedWindowTab(browser, { deps: SHARED_WINDOW_TOPOLOGY }) : await browser.newPage({ background: true });
1233
- registerManagedPage(page);
1234
- // The probe page carries the preset-sized layout at the display's density, the surface a capture page starts from before its own declaration, so the probe's
1235
- // acquisition keeps the shape the field validated.
1236
- const surface = await emulateLayoutSurface(page);
1237
- // Tears the probe page down cleanly: retire the raw capture stream (destroy plus the stop confirmation) while the browser is still connected, unregister the
1238
- // managed page, then close it. Shared by every success and self-timed-failure path in both modes.
1239
- const teardown = async (stream) => {
1240
- await retireRawStream(stream, clock);
1241
- unregisterManagedPage(page);
1242
- if (!page.isClosed()) {
1243
- await page.close();
1244
- }
1245
- };
1229
+ // The page the attempt opened, held outside the try so the catch can release it. It stays null until the open resolves, so an open that rejects leaves the catch
1230
+ // no page to close.
1231
+ let openedPage = null;
1246
1232
  try {
1233
+ /* The mid-life probe runs against a browser that is serving streams, so its page is opened as a tab of the shared window for the reason a stream's page is: a
1234
+ * probe tab rooted in a discovery window would hold that window open for as long as the probe took. The launch gate keeps the plain create: it runs
1235
+ * pre-publish, when the browser has exactly one window and no discovery window can exist yet, so its tab has no wrong window to land in - and a gate that has
1236
+ * to stay bounded gains nothing from queuing behind the selection executor to prove it.
1237
+ */
1238
+ const page = (mode.kind === "midlife") ? await openSharedWindowTab(browser, { deps: SHARED_WINDOW_TOPOLOGY }) : await browser.newPage({ background: true });
1239
+ openedPage = page;
1240
+ registerManagedPage(page);
1241
+ // The probe page carries the preset-sized layout at the display's density, the surface a capture page starts from before its own declaration, so the probe's
1242
+ // acquisition keeps the shape the field validated.
1243
+ const surface = await emulateLayoutSurface(page);
1244
+ // Tears the probe page down cleanly: retire the raw capture stream (destroy plus the stop confirmation) while the browser is still connected, unregister the
1245
+ // managed page, then close it. The launch gate, which races its acquisition internally, calls it on success; the mid-life probe, the only one that self-times
1246
+ // its acquisition, calls it before judging that latency, so the page is torn down whether the acquisition lands inside the bound or past it.
1247
+ const teardown = async (stream) => {
1248
+ await retireRawStream(stream, clock);
1249
+ unregisterManagedPage(page);
1250
+ if (!page.isClosed()) {
1251
+ await page.close();
1252
+ }
1253
+ };
1247
1254
  // Use the same capture MIME type and surface as the runtime. The stale state error occurs at the tabCapture API level before encoding matters, so matching
1248
1255
  // those runtime constraints ensures the probe exercises a representative acquisition. The constraints are held to the dimensions the declaration above
1249
1256
  // returned, so the probe holds its track to the surface the page actually carries rather than to a second read of the preset.
1250
- const useFFmpeg = CONFIG.streaming.captureMode === "ffmpeg";
1251
- const captureMimeType = useFFmpeg ? getCaptureMimeType() : NATIVE_FMP4_MIME_TYPE;
1257
+ const captureMimeType = getCaptureMimeType();
1252
1258
  const streamOptions = {
1253
1259
  audio: true,
1254
1260
  mimeType: captureMimeType,
@@ -1295,8 +1301,9 @@ async function attemptCaptureProbe(browser, mode, clock = systemClock) {
1295
1301
  const stream = await acquireCaptureStream(page, streamOptions);
1296
1302
  const elapsed = clock.now() - startedAt;
1297
1303
  await teardown(stream);
1298
- // Report failure when the caller abandoned this probe at the lock's outer deadline (unobservable in practice - the lock already rejected the caller - but it keeps
1299
- // the never-throw contract and prevents stranding a capture), or when the acquisition's own latency exceeded the criterion bound.
1304
+ // Report failure when the caller abandoned this probe at the lock's outer deadline (unobservable in practice - the lock already rejected the caller - but it
1305
+ // reports the abandonment as a returned string, the way every failed capture here is reported, and prevents stranding a capture), or when the acquisition's
1306
+ // own latency exceeded the criterion bound.
1300
1307
  if (mode.signal.aborted || (elapsed > mode.boundMs)) {
1301
1308
  return CAPTURE_PROBE_TIMEOUT_MESSAGE;
1302
1309
  }
@@ -1305,10 +1312,12 @@ async function attemptCaptureProbe(browser, mode, clock = systemClock) {
1305
1312
  }
1306
1313
  catch (error) {
1307
1314
  const errorMessage = formatError(error);
1308
- // Clean up the test page.
1309
- unregisterManagedPage(page);
1310
- if (!page.isClosed()) {
1311
- page.close().catch(() => { });
1315
+ // Clean up the test page, when the attempt got as far as opening one.
1316
+ if (openedPage) {
1317
+ unregisterManagedPage(openedPage);
1318
+ if (!openedPage.isClosed()) {
1319
+ openedPage.close().catch(() => { });
1320
+ }
1312
1321
  }
1313
1322
  return errorMessage;
1314
1323
  }
@@ -1342,8 +1351,8 @@ let captureVerdictInFlight = null;
1342
1351
  * Classifies a throw from the probe's run on the capture lock into a verdict or the absence of one. A turn-wait timeout means the probe never got to run: with
1343
1352
  * other streams tuning at the same moment it queued behind their legitimate acquisitions, and a lock that is busy says something about load, not about the browser
1344
1353
  * - a holder that is genuinely hung marks the browser through its own wedge. Every other value, the outer deadline above all, means the probe held its turn and its
1345
- * own acquisition did not settle, which is exactly the failure the mark exists for. Pure and total, so the distinction the detector rests on is pinnable without a
1346
- * browser.
1354
+ * own acquisition did not settle, which is exactly the failure the mark exists for. Pure and total, so a test can assert the distinction the detector rests on
1355
+ * without a browser.
1347
1356
  * @param error - The value the lock run threw.
1348
1357
  * @returns The outcome the detector acts on.
1349
1358
  */
@@ -1369,7 +1378,8 @@ async function probeCaptureSerialized(browser, timeout) {
1369
1378
  deadlineMessage: CAPTURE_PROBE_TIMEOUT_MESSAGE,
1370
1379
  deadlineMs: timeout + PROBE_TEARDOWN_ALLOWANCE_MS,
1371
1380
  // The probe's wedge is a loud warning only, never a second verdict: by wedge time the detector has already marked this browser from the returned failure
1372
- // (the outer deadline fires roughly 22s earlier), so a mark raised here would land on an instance that already carries one.
1381
+ // (the outer deadline always fires first, because the lock derives the wedge bound to land strictly after the deadline), so a mark raised here would land
1382
+ // on an instance that already carries one.
1373
1383
  onWedge: () => {
1374
1384
  LOG.warn("A mid-life capture probe has wedged past the recovery bound; the browser was already marked when the probe's deadline fired, so this wedge " +
1375
1385
  "is informational.");
@@ -1386,17 +1396,18 @@ async function probeCaptureSerialized(browser, timeout) {
1386
1396
  * Classifies an establishment failure and, when it carries a capture-infrastructure signature, hands it to the passive mid-life detector.
1387
1397
  *
1388
1398
  * This sits at the acquisition chokepoint rather than in one caller's catch, so every path that establishes a capture gets the detection: a fresh stream's setup,
1389
- * a tab replacement, and anything added later. Both of createPageWithCapture's own catch blocks call it, which is what extends the coverage across phases - the
1399
+ * a tab replacement, and anything added later. Each of createPageWithCapture's own catch blocks calls it, which is what extends the coverage across phases - the
1390
1400
  * acquisition catch sees a refused capture start, the establishment catch sees the playback-initialization timeout the pattern list also names.
1391
1401
  *
1392
- * Every logical setup classifies at most once. What holds that rule at the two call sites is where each call sits: each classifies the failure it is about to
1393
- * rethrow raw, and never one it is about to wrap for a retry. So a failure blamed on a direct watch URL classifies nothing, and the standing attempt that follows
1394
- * it classifies for both. The alternative would hand the readiness detector two failures from one setup, which is two background probes deciding a single
1395
- * browser's fate.
1402
+ * Classification is skipped for the typed direct-URL failure: a failure blamed on a direct watch URL leaves createPageWithCapture wrapped for a retry and
1403
+ * classifies nothing, and the standing attempt that follows it classifies for both. The refusal retry is the deliberate exception - the acquisition catch classifies the
1404
+ * first refusal and waits on its verdict, because waiting on that verdict is the retry policy. A retry that is refused again, or whose navigation then fails,
1405
+ * classifies a second time: the shared in-flight slot or the impairment already recorded against the browser absorbs that call when the first verdict is still
1406
+ * pending or marked the browser, and otherwise it is a fresh question about a browser that has failed again since its last verdict.
1396
1407
  *
1397
- * DirectUrlEstablishmentError is excluded here as well, as the backstop that keeps the rule true of a call site added later rather than as a filter the current
1398
- * two depend on: neither of them can reach this with one, because the type exists only to leave this function, and the path that wraps into it throws before
1399
- * the classification line is reached.
1408
+ * DirectUrlEstablishmentError is excluded here as well, as the backstop that keeps the skip true of a call site added later rather than as a filter the existing
1409
+ * call sites depend on: none of them can reach this with one, because the type exists only to leave createPageWithCapture, and the path that wraps into it throws
1410
+ * before the classification line is reached.
1400
1411
  * @param error - The failure the establishment is about to rethrow.
1401
1412
  * @returns The verdict the detector reached, or null when this failure called for none.
1402
1413
  */