prismcast 1.5.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/README.md +44 -28
  2. package/dist/app.d.ts +5 -0
  3. package/dist/app.js +63 -5
  4. package/dist/app.js.map +1 -1
  5. package/dist/browser/cdp.js +1 -1
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/channelSelection.d.ts +10 -34
  8. package/dist/browser/channelSelection.js +31 -96
  9. package/dist/browser/channelSelection.js.map +1 -1
  10. package/dist/browser/display.d.ts +19 -0
  11. package/dist/browser/display.js +16 -11
  12. package/dist/browser/display.js.map +1 -1
  13. package/dist/browser/index.d.ts +31 -54
  14. package/dist/browser/index.js +334 -272
  15. package/dist/browser/index.js.map +1 -1
  16. package/dist/browser/login.d.ts +65 -0
  17. package/dist/browser/login.js +193 -0
  18. package/dist/browser/login.js.map +1 -0
  19. package/dist/browser/precaching.js +15 -3
  20. package/dist/browser/precaching.js.map +1 -1
  21. package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
  22. package/dist/browser/tuning/comcastPolymer.js +655 -0
  23. package/dist/browser/tuning/comcastPolymer.js.map +1 -0
  24. package/dist/browser/tuning/cox.d.ts +2 -0
  25. package/dist/browser/tuning/cox.js +14 -0
  26. package/dist/browser/tuning/cox.js.map +1 -0
  27. package/dist/browser/tuning/directv.js +14 -1
  28. package/dist/browser/tuning/directv.js.map +1 -1
  29. package/dist/browser/tuning/fox.js +14 -2
  30. package/dist/browser/tuning/fox.js.map +1 -1
  31. package/dist/browser/tuning/hbo.js +12 -1
  32. package/dist/browser/tuning/hbo.js.map +1 -1
  33. package/dist/browser/tuning/hulu.js +24 -1
  34. package/dist/browser/tuning/hulu.js.map +1 -1
  35. package/dist/browser/tuning/shared.d.ts +46 -0
  36. package/dist/browser/tuning/shared.js +100 -0
  37. package/dist/browser/tuning/shared.js.map +1 -0
  38. package/dist/browser/tuning/sling.js +35 -5
  39. package/dist/browser/tuning/sling.js.map +1 -1
  40. package/dist/browser/tuning/spectrum.js +13 -1
  41. package/dist/browser/tuning/spectrum.js.map +1 -1
  42. package/dist/browser/tuning/thumbnailRow.js +1 -1
  43. package/dist/browser/tuning/thumbnailRow.js.map +1 -1
  44. package/dist/browser/tuning/tileClick.js +1 -1
  45. package/dist/browser/tuning/tileClick.js.map +1 -1
  46. package/dist/browser/tuning/xfinity.d.ts +2 -0
  47. package/dist/browser/tuning/xfinity.js +14 -0
  48. package/dist/browser/tuning/xfinity.js.map +1 -0
  49. package/dist/browser/tuning/youtubeTv.js +15 -1
  50. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  51. package/dist/browser/video.d.ts +20 -0
  52. package/dist/browser/video.js +168 -124
  53. package/dist/browser/video.js.map +1 -1
  54. package/dist/channels/index.d.ts +2 -2
  55. package/dist/channels/index.js +2075 -683
  56. package/dist/channels/index.js.map +1 -1
  57. package/dist/config/health.d.ts +28 -28
  58. package/dist/config/health.js +72 -54
  59. package/dist/config/health.js.map +1 -1
  60. package/dist/config/index.js +29 -64
  61. package/dist/config/index.js.map +1 -1
  62. package/dist/config/paths.d.ts +12 -0
  63. package/dist/config/paths.js +16 -0
  64. package/dist/config/paths.js.map +1 -1
  65. package/dist/config/profiles.d.ts +3 -2
  66. package/dist/config/profiles.js +45 -34
  67. package/dist/config/profiles.js.map +1 -1
  68. package/dist/config/providerPacks.js +4 -1
  69. package/dist/config/providerPacks.js.map +1 -1
  70. package/dist/config/providers.d.ts +19 -5
  71. package/dist/config/providers.js +123 -93
  72. package/dist/config/providers.js.map +1 -1
  73. package/dist/config/sites.d.ts +28 -0
  74. package/dist/config/sites.js +104 -127
  75. package/dist/config/sites.js.map +1 -1
  76. package/dist/config/userChannels.d.ts +53 -2
  77. package/dist/config/userChannels.js +222 -13
  78. package/dist/config/userChannels.js.map +1 -1
  79. package/dist/config/userConfig.d.ts +2 -1
  80. package/dist/config/userConfig.js +6 -15
  81. package/dist/config/userConfig.js.map +1 -1
  82. package/dist/config/userProfiles.d.ts +0 -1
  83. package/dist/config/userProfiles.js +18 -10
  84. package/dist/config/userProfiles.js.map +1 -1
  85. package/dist/hdhr/channelMap.d.ts +1 -1
  86. package/dist/hdhr/channelMap.js +4 -4
  87. package/dist/hdhr/channelMap.js.map +1 -1
  88. package/dist/index.js +25 -22
  89. package/dist/index.js.map +1 -1
  90. package/dist/native/decrypt.d.ts +32 -0
  91. package/dist/native/decrypt.js +85 -0
  92. package/dist/native/decrypt.js.map +1 -0
  93. package/dist/native/index.d.ts +61 -0
  94. package/dist/native/index.js +342 -0
  95. package/dist/native/index.js.map +1 -0
  96. package/dist/native/intercept.d.ts +35 -0
  97. package/dist/native/intercept.js +184 -0
  98. package/dist/native/intercept.js.map +1 -0
  99. package/dist/native/probe.d.ts +49 -0
  100. package/dist/native/probe.js +294 -0
  101. package/dist/native/probe.js.map +1 -0
  102. package/dist/native/proxy.d.ts +54 -0
  103. package/dist/native/proxy.js +947 -0
  104. package/dist/native/proxy.js.map +1 -0
  105. package/dist/routes/components.d.ts +43 -0
  106. package/dist/routes/components.js +122 -0
  107. package/dist/routes/components.js.map +1 -1
  108. package/dist/routes/config/channels/index.d.ts +2 -2
  109. package/dist/routes/config/channels/index.js +1 -1
  110. package/dist/routes/config/channels/index.js.map +1 -1
  111. package/dist/routes/config/channels/routes.js +443 -65
  112. package/dist/routes/config/channels/routes.js.map +1 -1
  113. package/dist/routes/config/channels/table.d.ts +76 -0
  114. package/dist/routes/config/channels/table.js +369 -89
  115. package/dist/routes/config/channels/table.js.map +1 -1
  116. package/dist/routes/config/index.d.ts +1 -1
  117. package/dist/routes/config/index.js +1 -1
  118. package/dist/routes/config/index.js.map +1 -1
  119. package/dist/routes/config/providers.d.ts +6 -6
  120. package/dist/routes/config/providers.js +73 -99
  121. package/dist/routes/config/providers.js.map +1 -1
  122. package/dist/routes/debug.js +5 -5
  123. package/dist/routes/debug.js.map +1 -1
  124. package/dist/routes/hls.js +12 -2
  125. package/dist/routes/hls.js.map +1 -1
  126. package/dist/routes/index.d.ts +0 -15
  127. package/dist/routes/index.js +2 -16
  128. package/dist/routes/index.js.map +1 -1
  129. package/dist/routes/providers.js +106 -3
  130. package/dist/routes/providers.js.map +1 -1
  131. package/dist/routes/root/content.js +124 -47
  132. package/dist/routes/root/content.js.map +1 -1
  133. package/dist/routes/root/index.js +2 -1
  134. package/dist/routes/root/index.js.map +1 -1
  135. package/dist/routes/root/scripts/channels.js +772 -312
  136. package/dist/routes/root/scripts/channels.js.map +1 -1
  137. package/dist/routes/root/scripts/config.js +596 -544
  138. package/dist/routes/root/scripts/config.js.map +1 -1
  139. package/dist/routes/root/scripts/index.d.ts +1 -0
  140. package/dist/routes/root/scripts/index.js +1 -0
  141. package/dist/routes/root/scripts/index.js.map +1 -1
  142. package/dist/routes/root/scripts/shared.d.ts +6 -0
  143. package/dist/routes/root/scripts/shared.js +391 -0
  144. package/dist/routes/root/scripts/shared.js.map +1 -0
  145. package/dist/routes/root/scripts/status.js +49 -33
  146. package/dist/routes/root/scripts/status.js.map +1 -1
  147. package/dist/routes/root/styles.js +117 -10
  148. package/dist/routes/root/styles.js.map +1 -1
  149. package/dist/service/commands.js +2 -1
  150. package/dist/service/commands.js.map +1 -1
  151. package/dist/streaming/fmp4Segmenter.d.ts +5 -0
  152. package/dist/streaming/fmp4Segmenter.js +146 -42
  153. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  154. package/dist/streaming/hls.d.ts +24 -19
  155. package/dist/streaming/hls.js +603 -297
  156. package/dist/streaming/hls.js.map +1 -1
  157. package/dist/streaming/hlsResume.d.ts +16 -3
  158. package/dist/streaming/hlsResume.js +35 -8
  159. package/dist/streaming/hlsResume.js.map +1 -1
  160. package/dist/streaming/hlsSegments.d.ts +41 -2
  161. package/dist/streaming/hlsSegments.js +131 -28
  162. package/dist/streaming/hlsSegments.js.map +1 -1
  163. package/dist/streaming/lifecycle.js +27 -13
  164. package/dist/streaming/lifecycle.js.map +1 -1
  165. package/dist/streaming/monitor.d.ts +1 -0
  166. package/dist/streaming/monitor.js +862 -474
  167. package/dist/streaming/monitor.js.map +1 -1
  168. package/dist/streaming/mp4Parser.d.ts +52 -6
  169. package/dist/streaming/mp4Parser.js +156 -30
  170. package/dist/streaming/mp4Parser.js.map +1 -1
  171. package/dist/streaming/mpegts.d.ts +2 -2
  172. package/dist/streaming/mpegts.js +156 -109
  173. package/dist/streaming/mpegts.js.map +1 -1
  174. package/dist/streaming/playlistBuilder.d.ts +38 -0
  175. package/dist/streaming/playlistBuilder.js +66 -0
  176. package/dist/streaming/playlistBuilder.js.map +1 -0
  177. package/dist/streaming/preroll.d.ts +126 -0
  178. package/dist/streaming/preroll.js +434 -0
  179. package/dist/streaming/preroll.js.map +1 -0
  180. package/dist/streaming/pretune.d.ts +8 -0
  181. package/dist/streaming/pretune.js +226 -0
  182. package/dist/streaming/pretune.js.map +1 -0
  183. package/dist/streaming/recovery.d.ts +0 -6
  184. package/dist/streaming/recovery.js +0 -8
  185. package/dist/streaming/recovery.js.map +1 -1
  186. package/dist/streaming/registry.d.ts +57 -9
  187. package/dist/streaming/registry.js +26 -7
  188. package/dist/streaming/registry.js.map +1 -1
  189. package/dist/streaming/setup.d.ts +8 -1
  190. package/dist/streaming/setup.js +71 -27
  191. package/dist/streaming/setup.js.map +1 -1
  192. package/dist/streaming/showInfo.d.ts +29 -4
  193. package/dist/streaming/showInfo.js +210 -38
  194. package/dist/streaming/showInfo.js.map +1 -1
  195. package/dist/streaming/statusEmitter.d.ts +20 -6
  196. package/dist/streaming/statusEmitter.js +31 -7
  197. package/dist/streaming/statusEmitter.js.map +1 -1
  198. package/dist/types/channels.d.ts +32 -0
  199. package/dist/types/config.d.ts +2 -2
  200. package/dist/types/index.d.ts +2 -2
  201. package/dist/types/index.js.map +1 -1
  202. package/dist/types/profiles.d.ts +13 -1
  203. package/dist/types/selection.d.ts +5 -1
  204. package/dist/types/shared.d.ts +1 -1
  205. package/dist/types/streaming.d.ts +7 -0
  206. package/dist/utils/chromeFetch.d.ts +20 -0
  207. package/dist/utils/chromeFetch.js +43 -0
  208. package/dist/utils/chromeFetch.js.map +1 -0
  209. package/dist/utils/debugFilter.d.ts +0 -5
  210. package/dist/utils/debugFilter.js +20 -11
  211. package/dist/utils/debugFilter.js.map +1 -1
  212. package/dist/utils/delay.d.ts +28 -0
  213. package/dist/utils/delay.js +35 -1
  214. package/dist/utils/delay.js.map +1 -1
  215. package/dist/utils/evaluate.js +6 -11
  216. package/dist/utils/evaluate.js.map +1 -1
  217. package/dist/utils/ffmpeg.d.ts +14 -12
  218. package/dist/utils/ffmpeg.js +114 -134
  219. package/dist/utils/ffmpeg.js.map +1 -1
  220. package/dist/utils/format.d.ts +6 -0
  221. package/dist/utils/format.js +8 -0
  222. package/dist/utils/format.js.map +1 -1
  223. package/dist/utils/index.d.ts +3 -0
  224. package/dist/utils/index.js +3 -0
  225. package/dist/utils/index.js.map +1 -1
  226. package/dist/utils/logEmitter.js +1 -1
  227. package/dist/utils/logEmitter.js.map +1 -1
  228. package/dist/utils/pid.d.ts +40 -0
  229. package/dist/utils/pid.js +83 -0
  230. package/dist/utils/pid.js.map +1 -0
  231. package/dist/utils/retry.d.ts +15 -9
  232. package/dist/utils/retry.js +7 -23
  233. package/dist/utils/retry.js.map +1 -1
  234. package/dist/utils/timing.d.ts +5 -0
  235. package/dist/utils/timing.js +13 -0
  236. package/dist/utils/timing.js.map +1 -0
  237. package/dist/utils/version.js +2 -2
  238. package/dist/utils/version.js.map +1 -1
  239. package/package.json +7 -7
@@ -1,32 +1,27 @@
1
- import { EvaluateTimeoutError, LOG, formatError, getAbortSignal, isSessionClosedError, runWithStreamContext, startTimer } from "../utils/index.js";
2
- import { RECOVERY_METHODS, capitalize, checkCircuitBreaker, createRecoveryMetrics, formatIssueType, formatRecoveryDuration, getIssueCategory, getIssueDescription, getRecoveryMethod, recordRecoveryAttempt, recordRecoverySuccess, resetCircuitBreaker } from "./recovery.js";
1
+ import { EvaluateTimeoutError, LOG, capitalize, formatError, getAbortSignal, isSessionClosedError, runWithStreamContext, startTimer } from "../utils/index.js";
2
+ import { RECOVERY_METHODS, checkCircuitBreaker, createRecoveryMetrics, formatIssueType, formatRecoveryDuration, getIssueCategory, getIssueDescription, getRecoveryMethod, recordRecoveryAttempt, recordRecoverySuccess, resetCircuitBreaker } from "./recovery.js";
3
3
  import { applyVideoStyles, buildVideoSelectorType, checkVideoPresence, enforceVideoVolume, ensurePlayback, findVideoContext, getVideoState, tuneToChannel, validateVideoElement, verifyFullscreen } from "../browser/video.js";
4
- import { getChannelLogo, getShowName } from "./showInfo.js";
5
- import { getLastSegmentSize, getStream, getStreamMemoryUsage } from "./registry.js";
4
+ import { getLastSegmentHasVideo, getLastSegmentSize, getStream, getStreamMemoryUsage } from "./registry.js";
6
5
  import { CONFIG } from "../config/index.js";
6
+ import { clearProbeCache } from "../native/probe.js";
7
7
  import { emitStreamHealthChanged } from "./statusEmitter.js";
8
+ import { getChannelLogo } from "../config/userChannels.js";
8
9
  import { getClientSummary } from "./clients.js";
10
+ import { getEffectiveViewport } from "../config/presets.js";
11
+ import { getProviderBySlug } from "../browser/channelSelection.js";
12
+ import { getShowName } from "./showInfo.js";
13
+ import { refreshNativeManifest } from "../native/index.js";
9
14
  import { resizeAndMinimizeWindow } from "../browser/cdp.js";
10
15
  export function monitorPlaybackHealth(page, context, profile, url, streamId, streamInfo, onCircuitBreak, onTabReplacement) {
11
- /* Monitor state variables. These track the video's behavior over time and control recovery decisions.
16
+ /* Monitor state. These track the video's behavior over time and control recovery decisions. Mutable variables are organized into typed state objects by subsystem
17
+ * (recovery, segments, native health, resolution) to clarify ownership and interaction boundaries. Variables that don't belong to a specific subsystem remain as
18
+ * standalone declarations.
12
19
  */
13
20
  // The current page reference. This can change after tab replacement recovery, when the old tab is closed and a new one is created. We use a mutable variable so we
14
21
  // can update the reference after replacement.
15
22
  let currentPage = page;
16
23
  // The video's currentTime from the previous check. Used to detect whether the video is progressing. Null on first check since we have no previous value.
17
24
  let lastTime = null;
18
- // Number of consecutive checks where currentTime did not advance. We require multiple consecutive stalls before triggering recovery to avoid reacting to momentary
19
- // hiccups. Reset to 0 when progression is detected.
20
- let stallCount = 0;
21
- // Number of consecutive checks where the video reports paused state. Like stallCount, we require multiple consecutive paused checks (> stallCountThreshold) before
22
- // triggering recovery. This filters out transient rebuffer pauses where the player briefly pauses itself to refill its buffer and resumes on its own. Without this
23
- // hysteresis, every transient rebuffer pause triggers an unnecessary L1 recovery (play/unmute) that logs noise but does nothing useful.
24
- let pauseCount = 0;
25
- // Current escalation level (0-4). Level 0 means no recovery needed. Each time recovery is triggered, this increments to try increasingly aggressive actions.
26
- // Resets to 0 after sustained healthy playback.
27
- let escalationLevel = 0;
28
- // Timestamp of the last recovery attempt. Used to calculate healthy playback duration for escalation reset.
29
- let lastRecoveryTime = 0;
30
25
  // Timestamp when buffering started, or null if not currently buffering. Used to apply the buffering grace period - we don't trigger recovery for buffering until
31
26
  // it exceeds BUFFERING_GRACE_PERIOD.
32
27
  let bufferingStartTime = null;
@@ -35,21 +30,6 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
35
30
  // Counter for consecutive page navigation failures. If navigation fails twice in a row, we fall back to source reload (level 2) instead. This prevents getting
36
31
  // stuck in a loop when navigation itself is the problem.
37
32
  let consecutiveNavigationFailures = 0;
38
- // Track whether source reload (L2) has been attempted in the current page session. Log analysis shows the first source reload often works (~58%), but the second
39
- // always fails and leaves the video at readyState=0. When this flag is true and recovery is needed, we skip L2 and go directly to L3 (page reload).
40
- let sourceReloadAttempted = false;
41
- // Segment production monitoring state. After L2/L3 recovery, we track whether segments are actually being produced. If recovery reports success but the capture
42
- // pipeline is dead (MediaRecorder stopped producing data), we need to escalate to tab replacement.
43
- let preRecoverySegmentIndex = null; // Segment index when recovery started, used to detect if new segments are produced.
44
- let segmentWaitStartTime = null; // Timestamp when we started waiting for segment production after recovery grace period.
45
- let segmentProductionStalled = false; // Flag indicating segment production has stalled after recovery.
46
- // Continuous segment size and staleness monitoring state. Detects spontaneous capture pipeline death (no preceding recovery event) by checking segment sizes and
47
- // production rate. Dead pipelines may produce tiny segments (18 bytes observed) or stop producing segments entirely, while the video element appears healthy. This
48
- // complements post-recovery index monitoring.
49
- let lastCheckedSegmentIndex = 0; // Last segment index we inspected (to detect new segments).
50
- let lastSegmentAdvanceTime = Date.now(); // Timestamp when segment index last advanced. Used for staleness detection.
51
- let consecutiveTinySegments = 0; // Count of consecutive tiny segments.
52
- let wasInTinySegmentState = false; // For detecting spontaneous recovery (tiny→valid transition without explicit recovery).
53
33
  // Track whether the browser window needs to be re-minimized after recovery. Recovery actions (especially ensureFullscreen) can cause the window to un-minimize.
54
34
  // We set this flag when recovery is triggered and clear it after the recovery grace period passes and we see a healthy check.
55
35
  let pendingReMinimize = false;
@@ -59,8 +39,6 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
59
39
  let fullscreenReapplyCount = 0;
60
40
  // Flag indicating the cleanup function was called. When true, the next interval check will clear itself.
61
41
  let intervalCleared = false;
62
- // Flag indicating a recovery operation is in progress. We skip health checks during recovery to avoid triggering additional recovery while one is running.
63
- let recoveryInProgress = false;
64
42
  // The current video context (page or frame). This can change after a page navigation recovery, when we need to find the new video context.
65
43
  let currentContext = context;
66
44
  // Circuit breaker state. Tracks total failures within a time window and trips (terminates the stream) when too many failures occur.
@@ -71,8 +49,6 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
71
49
  // Counter for consecutive evaluate timeouts. When the browser tab becomes unresponsive, evaluate() calls will timeout instead of returning data. After 3
72
50
  // consecutive timeouts, we trigger tab replacement recovery (if the callback is provided). Reset to 0 on successful getVideoState().
73
51
  let consecutiveTimeouts = 0;
74
- // Total recovery attempts for status reporting.
75
- let totalRecoveryAttempts = 0;
76
52
  // Last known video state for status reporting.
77
53
  let lastVideoState = null;
78
54
  // Recovery metrics tracked throughout the stream's lifetime.
@@ -90,15 +66,28 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
90
66
  // segments contain only audio data. Audio is transcoded at a controlled bitrate (max 512Kbps), so audio-only segments are at most ~192KB for 3-second segments.
91
67
  // The 500KB threshold catches both dead captures (18 bytes) and audio-only captures while staying well below the smallest video preset (480p/3Mbps ≈ 750KB/segment).
92
68
  const TINY_SEGMENT_THRESHOLD = 512000; // 500KB - segments below this indicate dead or degraded capture.
93
- const TINY_SEGMENT_COUNT_TRIGGER = 10; // Trigger recovery after 10 consecutive tiny segments (~20 seconds with 2-second segments).
69
+ const TINY_SEGMENT_COUNT_TRIGGER = 10; // Default trigger count: 10 consecutive tiny segments (~20 seconds with 2-second segments).
70
+ // Resolve the provider-specific tiny segment count threshold once at monitor startup. Providers with extended static content (e.g., Xfinity commercial
71
+ // placeholders) set a higher value to tolerate longer periods of small segments without false positive tab replacements. Dead capture pipelines (segments with
72
+ // no video trafs) always use TINY_SEGMENT_COUNT_TRIGGER regardless of this setting.
73
+ const providerModule = streamInfo.providerTag ? getProviderBySlug(streamInfo.providerTag) : undefined;
74
+ const providerTinySegmentThreshold = providerModule?.tinySegmentThreshold ?? TINY_SEGMENT_COUNT_TRIGGER;
94
75
  // Segment staleness timeout. When no new segments have been produced for this duration, the capture pipeline is considered dead even though the video element may
95
76
  // appear healthy. This catches the case where Chrome's MediaRecorder silently stops emitting data without raising an error — the input stream stays "open" but no
96
77
  // data events fire. The 20-second threshold is 4x the maximum expected moof delivery interval (5 seconds) to avoid false positives during normal bursty delivery.
97
78
  const SEGMENT_STALENESS_TIMEOUT = 20000; // 20 seconds.
79
+ // Resolution degradation detection. When the video element's intrinsic resolution is significantly below the configured viewport, the provider's ABR is delivering
80
+ // low-quality content. The threshold is expressed as a ratio — if either dimension is below this fraction of the viewport, the resolution is considered degraded.
81
+ // 50% catches clear ABR degradation (768×432 on 1080p = 40%) while allowing legitimate 720p content on 1080p (67% > 50%).
82
+ const RESOLUTION_RATIO_THRESHOLD = 0.5;
83
+ // Grace period in milliseconds after stream start and after each recovery action. Gives ABR time to ramp up before flagging degradation.
84
+ const RESOLUTION_GRACE_PERIOD = 30000;
85
+ // Number of consecutive degraded readings required before triggering recovery. At ~2 seconds per monitor tick, 15 readings = ~30 seconds of sustained
86
+ // degradation. This lets transient ABR dips (commercial breaks, ad transitions) self-heal without unnecessary page reloads.
87
+ const RESOLUTION_DEGRADED_COUNT_THRESHOLD = 15;
98
88
  // Fixed margin in milliseconds before the maxContinuousPlayback limit at which a proactive reload is triggered. Two minutes provides enough time for page
99
89
  // navigation and video reinitialization to complete before the site enforces its cutoff.
100
90
  const PROACTIVE_RELOAD_MARGIN_MS = 120000;
101
- let recoveryGraceUntil = 0;
102
91
  // Timestamp of the most recent full page navigation. Used to calculate elapsed continuous playback for proactive reload when maxContinuousPlayback is configured.
103
92
  // Initialized to Date.now() because the monitor starts immediately after tuneToChannel() succeeds in stream setup, meaning a page load just completed. Reset
104
93
  // after any successful page navigation recovery or tab replacement, but NOT after source reloads (L2) which preserve the page's JavaScript context.
@@ -107,6 +96,265 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
107
96
  const selectorType = buildVideoSelectorType(profile);
108
97
  // Capture stream context for re-establishing on each interval tick. AsyncLocalStorage context is lost when entering setInterval callbacks.
109
98
  const streamContext = { channelName: streamInfo.channelName ?? undefined, streamId, url };
99
+ // Recovery state. Tracks escalation level, failure counters, and recovery lifecycle flags that control the decision to trigger recovery and which method to use.
100
+ const recoveryState = {
101
+ escalationLevel: 0,
102
+ graceUntil: 0,
103
+ inProgress: false,
104
+ lastRecoveryTime: 0,
105
+ pauseCount: 0,
106
+ sourceReloadAttempted: false,
107
+ stallCount: 0,
108
+ totalAttempts: 0
109
+ };
110
+ // Segment production state. Tracks both post-recovery segment verification and continuous segment size monitoring. After L2/L3 recovery, we verify segments are
111
+ // actually being produced. Independently, we monitor segment sizes on every tick to detect spontaneous capture pipeline death (dead pipelines produce tiny segments
112
+ // while the video element appears healthy).
113
+ const segmentState = {
114
+ consecutiveTinySegments: 0,
115
+ lastCheckedIndex: 0,
116
+ lastSegmentAdvanceTime: Date.now(),
117
+ preRecoveryIndex: null,
118
+ productionStalled: false,
119
+ waitStartTime: null,
120
+ wasInTinyState: false
121
+ };
122
+ // Native stream health state. Only used when the stream is in native mode. Tracks segment delivery health to detect stalled streams where the provider's manifest
123
+ // stops advancing or segments stop arriving. Native recovery uses recoveryState.inProgress rather than a separate flag, since the interval callback already checks
124
+ // that flag before dispatching to either the native or capture-mode health check path.
125
+ const nativeHealthState = {
126
+ issueTime: null,
127
+ issueType: null,
128
+ lastCheckedSegmentIndex: 0,
129
+ lastSegmentAdvanceTime: Date.now(),
130
+ recoveryAttempts: 0
131
+ };
132
+ // Resolution degradation monitoring. Separate from the recovery escalation (L1-L4) which handles broken playback. Resolution degradation is a quality issue - the
133
+ // stream works but at lower-than-expected resolution. Uses its own tracking and two-step escalation: page reload, then tab replacement.
134
+ const resolutionState = {
135
+ consecutiveDegradedReadings: 0,
136
+ graceEnd: Date.now() + RESOLUTION_GRACE_PERIOD,
137
+ recoveryAttempt: 0
138
+ };
139
+ /**
140
+ * Checks segment delivery health for native streams. Detects stalled streams by comparing the proxy's segment index and last segment timestamp against thresholds.
141
+ * Recovery follows three escalation levels per the plan:
142
+ *
143
+ * - L1: Re-fetch manifest (handled by the proxy's internal retry loop — consecutive failures up to the threshold)
144
+ * - L2: Reload page for fresh tokens (same mechanism as proactive token refresh, but triggered by segment staleness)
145
+ * - L3: Fall back to capture mode via tab replacement (stops native proxy, creates fresh page with capture pipeline)
146
+ *
147
+ * Note: The `recoveryState.inProgress` guard at the top of the interval callback prevents re-entry during async L2/L3 recovery. The native path does not need its
148
+ * own guard — it reuses the shared flag.
149
+ *
150
+ * @param entry - The stream registry entry for the native stream.
151
+ */
152
+ function checkNativeStreamHealth(entry) {
153
+ const proxy = entry.nativeProxy;
154
+ if (!proxy) {
155
+ emitStatusUpdate();
156
+ return;
157
+ }
158
+ const now = Date.now();
159
+ const currentSegmentIndex = proxy.getSegmentIndex();
160
+ const lastSegmentTime = proxy.getLastSegmentTime();
161
+ const targetDuration = proxy.getTargetDuration();
162
+ const consecutiveErrors = proxy.getConsecutiveErrors();
163
+ const storeKey = entry.info.storeKey;
164
+ // Fast path: if the proxy hit its error threshold and stopped itself, trigger L3 fallback immediately. This avoids waiting for the staleness threshold when hard
165
+ // errors (HTTP 403, network failures) have already been detected by the proxy's internal retry loop.
166
+ if (proxy.hasErrored()) {
167
+ LOG.debug("native:monitor", "Native proxy errored for %s. Initiating capture fallback.", storeKey);
168
+ nativeHealthState.issueType = "proxy error";
169
+ nativeHealthState.issueTime = now;
170
+ void runWithStreamContext(streamContext, async () => {
171
+ await executeNativeL3Fallback(entry);
172
+ });
173
+ return;
174
+ }
175
+ // Check if new segments have been produced since the last tick.
176
+ if (currentSegmentIndex > nativeHealthState.lastCheckedSegmentIndex) {
177
+ nativeHealthState.lastCheckedSegmentIndex = currentSegmentIndex;
178
+ nativeHealthState.lastSegmentAdvanceTime = now;
179
+ // Clear any previous issue tracking when segments are flowing.
180
+ if (nativeHealthState.issueType) {
181
+ nativeHealthState.issueType = null;
182
+ nativeHealthState.issueTime = null;
183
+ nativeHealthState.recoveryAttempts = 0;
184
+ LOG.debug("native:monitor", "Native stream healthy for %s. Segments advancing (index %s).", storeKey, currentSegmentIndex);
185
+ }
186
+ }
187
+ // Calculate staleness: time since the last new segment was produced.
188
+ const stalenessMs = now - nativeHealthState.lastSegmentAdvanceTime;
189
+ const stalenessThreshold = targetDuration * 2 * 1000;
190
+ // Classify health based on segment delivery metrics.
191
+ let nativeHealth = "healthy";
192
+ if (consecutiveErrors > 0) {
193
+ nativeHealth = "recovering";
194
+ if (!nativeHealthState.issueType) {
195
+ nativeHealthState.issueType = "fetch errors";
196
+ nativeHealthState.issueTime = now;
197
+ }
198
+ LOG.debug("native:monitor", "Native stream recovering for %s. Consecutive errors: %s.", storeKey, consecutiveErrors);
199
+ }
200
+ else if ((stalenessMs > stalenessThreshold) && (lastSegmentTime > 0)) {
201
+ // Only flag staleness after at least one segment has been produced (lastSegmentTime > 0) and the staleness exceeds the threshold.
202
+ nativeHealth = "stalled";
203
+ if (!nativeHealthState.issueType) {
204
+ nativeHealthState.issueType = "segment stall";
205
+ nativeHealthState.issueTime = now;
206
+ }
207
+ const staleSec = Math.round(stalenessMs / 1000);
208
+ LOG.debug("native:monitor", "Native stream stalled for %s. No new segments in %ss (threshold: %ss).", storeKey, staleSec, Math.round(stalenessThreshold / 1000));
209
+ // L2: At 4× target duration, attempt a page reload for fresh tokens. This recovers from auth expiry where the manifest URL is still valid but segment URLs
210
+ // are rejected. The proxy continues serving cached segments during the reload.
211
+ if ((stalenessMs > (targetDuration * 4 * 1000)) && (nativeHealthState.recoveryAttempts === 0)) {
212
+ LOG.warn("Native stream stalled for %s. No new segments in %ss. Attempting recovery.", storeKey, staleSec);
213
+ nativeHealthState.recoveryAttempts++;
214
+ void runWithStreamContext(streamContext, async () => {
215
+ await executeNativeL2Recovery(entry);
216
+ });
217
+ return;
218
+ }
219
+ // L3: At 6× target duration (or if L2 was already attempted), fall back to capture mode via tab replacement.
220
+ if ((stalenessMs > (targetDuration * 6 * 1000)) || ((stalenessMs > (targetDuration * 4 * 1000)) && (nativeHealthState.recoveryAttempts > 0))) {
221
+ LOG.warn("Falling back to capture mode for %s: native streaming stalled after recovery attempt.", storeKey);
222
+ void runWithStreamContext(streamContext, async () => {
223
+ await executeNativeL3Fallback(entry);
224
+ });
225
+ return;
226
+ }
227
+ }
228
+ emitNativeStatus(entry, nativeHealth);
229
+ }
230
+ /**
231
+ * Emits a status update with native-specific health classification. Populates meaningful fields (health, issue tracking, memory, clients) and zeroes video-specific
232
+ * fields that are not applicable to native streams.
233
+ *
234
+ * @param entry - The stream registry entry.
235
+ * @param health - The health status to report.
236
+ */
237
+ function emitNativeStatus(entry, health) {
238
+ if (intervalCleared) {
239
+ return;
240
+ }
241
+ const now = Date.now();
242
+ const memoryBytes = getStreamMemoryUsage(entry).total;
243
+ const channelKey = entry.info.storeKey;
244
+ const clientSummary = getClientSummary(streamInfo.numericStreamId);
245
+ const escalation = (health === "stalled") ? 1 : ((health === "recovering") ? 2 : 0);
246
+ const status = {
247
+ bufferingDuration: null,
248
+ captureCodec: entry.captureCodec,
249
+ channel: streamInfo.channelName,
250
+ clientCount: clientSummary.total,
251
+ clients: clientSummary.clients,
252
+ currentTime: 0,
253
+ duration: Math.round((now - streamInfo.startTime.getTime()) / 1000),
254
+ escalationLevel: escalation,
255
+ hardwareAccelerated: entry.hardwareAccelerated,
256
+ health,
257
+ id: streamInfo.numericStreamId,
258
+ lastIssueTime: nativeHealthState.issueTime,
259
+ lastIssueType: nativeHealthState.issueType,
260
+ lastRecoveryTime: null,
261
+ logoUrl: channelKey ? (getChannelLogo(channelKey) ?? "") : "",
262
+ memoryBytes,
263
+ nativeBandwidth: entry.nativeBandwidth,
264
+ nativeResolution: entry.nativeResolution,
265
+ networkState: 0,
266
+ pageReloadsInWindow: 0,
267
+ providerName: streamInfo.providerName,
268
+ readyState: 0,
269
+ recoveryAttempts: nativeHealthState.recoveryAttempts,
270
+ showName: getShowName(streamInfo.numericStreamId),
271
+ startTime: streamInfo.startTime.toISOString(),
272
+ streamingMode: entry.streamingMode,
273
+ url
274
+ };
275
+ emitStreamHealthChanged(status);
276
+ }
277
+ /**
278
+ * L2 recovery for native streams: reloads the page to get fresh authentication tokens and re-intercepts the manifest. Delegates to the shared refreshNativeManifest
279
+ * helper in the coordinator module, which handles interceptor installation, navigation, probing, isStopped() guards, and proxy updates.
280
+ *
281
+ * @param entry - The stream registry entry.
282
+ */
283
+ async function executeNativeL2Recovery(entry) {
284
+ const proxy = entry.nativeProxy;
285
+ if (!proxy || proxy.isStopped()) {
286
+ return;
287
+ }
288
+ recoveryState.inProgress = true;
289
+ LOG.debug("native:monitor", "Starting L2 recovery (page reload) for %s.", entry.info.storeKey);
290
+ try {
291
+ const success = await refreshNativeManifest({
292
+ channelName: entry.info.storeKey,
293
+ page: currentPage,
294
+ proxy,
295
+ streamIdStr: streamId,
296
+ url
297
+ });
298
+ if (success) {
299
+ // Reset staleness tracking so the monitor gives the refreshed stream time to produce segments.
300
+ nativeHealthState.lastSegmentAdvanceTime = Date.now();
301
+ }
302
+ }
303
+ finally {
304
+ recoveryState.inProgress = false;
305
+ }
306
+ }
307
+ /**
308
+ * L3 recovery for native streams: falls back to capture mode via tab replacement. Stops the native proxy, creates a fresh page with capture pipeline, and switches
309
+ * the stream to capture mode. The existing tab replacement infrastructure handles page creation, capture initialization, segmenter creation, and registry updates.
310
+ *
311
+ * If the proxy's onError fires concurrently (from the poll loop hitting its failure threshold), terminateStream runs before this async function gets a chance to
312
+ * execute. By the time L3 runs, the stream is already terminated and executeTabReplacement returns null, which we handle as a failed outcome.
313
+ *
314
+ * @param entry - The stream registry entry.
315
+ */
316
+ async function executeNativeL3Fallback(entry) {
317
+ if (!onTabReplacement) {
318
+ LOG.warn("Capture fallback not available for %s: no tab replacement handler.", entry.info.storeKey);
319
+ onCircuitBreak();
320
+ return;
321
+ }
322
+ LOG.debug("native:monitor", "Starting L3 fallback (capture mode) for %s.", entry.info.storeKey);
323
+ // Stop the native proxy before tab replacement closes the page. The proxy may still be polling and would encounter errors when the page navigates away.
324
+ if (entry.nativeProxy) {
325
+ entry.nativeProxy.stop();
326
+ entry.nativeProxy = null;
327
+ }
328
+ // Use the existing tab replacement infrastructure. It sets recoveryState.inProgress = true internally and clears it in finalizeTabReplacement. It creates a new
329
+ // page with capture, navigates, sets up playback, creates a segmenter, and updates the registry entry (page, rawCaptureStream, ffmpegProcess, segmenter).
330
+ const outcome = await executeTabReplacement("native fallback to capture");
331
+ if (outcome.outcome === "success") {
332
+ // Tab replacement succeeded. Update the registry to reflect capture mode. The tab replacement handler already set page, rawCaptureStream, ffmpegProcess,
333
+ // and segmenter on the registry entry. We just need to update the streaming mode and clear audio state.
334
+ entry.streamingMode = "capture";
335
+ // Clear separate audio state from the native proxy. Without this, hasAudio remains true and the HLS handler continues serving the master playlist (referencing
336
+ // video.m3u8 and audio.m3u8) instead of the capture segmenter's variant playlist. Clients that cached the master playlist structure would request stale audio
337
+ // and video variant playlists pointing to segments that are no longer being updated.
338
+ entry.hls.hasAudio = false;
339
+ entry.hls.audioPlaylist = "";
340
+ entry.hls.audioSegments.clear();
341
+ entry.hls.audioSegmentBytes = 0;
342
+ entry.hls.videoPlaylist = "";
343
+ // Clear the probe cache so subsequent tunes to this channel don't re-attempt native streaming.
344
+ clearProbeCache(entry.info.storeKey);
345
+ LOG.info("Switched to capture mode for %s: native streaming failed.", entry.info.storeKey);
346
+ // The monitor's next tick will see streamingMode === "capture" and run the normal video element monitoring path. The state reset from
347
+ // applyTabReplacementSuccess (called by executeTabReplacement) already initialized all capture-mode monitor variables.
348
+ }
349
+ else if (outcome.outcome === "terminated") {
350
+ // Circuit breaker tripped during tab replacement. Stream is being terminated.
351
+ LOG.warn("Capture fallback failed for %s: circuit breaker tripped.", entry.info.storeKey);
352
+ }
353
+ else {
354
+ // Tab replacement failed but stream wasn't terminated. The circuit breaker will handle it on the next failure.
355
+ LOG.warn("Capture fallback failed for %s: tab replacement unsuccessful.", entry.info.storeKey);
356
+ }
357
+ }
110
358
  // Helper to mark a discontinuity in the HLS playlist after recovery events that disrupt the video source. The segmenter flushes its current fragment buffer and sets
111
359
  // a pending discontinuity flag so the next segment boundary includes an #EXT-X-DISCONTINUITY tag. This tells HLS clients to flush their decoder state.
112
360
  const markStreamDiscontinuity = () => {
@@ -122,11 +370,11 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
122
370
  return "error";
123
371
  }
124
372
  // If we're at escalation level 3 (page reload), we're in serious trouble.
125
- if (escalationLevel >= 3) {
373
+ if (recoveryState.escalationLevel >= 3) {
126
374
  return "error";
127
375
  }
128
376
  // If we're actively recovering (levels 1-2).
129
- if (escalationLevel > 0) {
377
+ if (recoveryState.escalationLevel > 0) {
130
378
  return "recovering";
131
379
  }
132
380
  // If we're buffering (within grace period).
@@ -134,7 +382,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
134
382
  return "buffering";
135
383
  }
136
384
  // If we have consecutive stalls but not yet triggering recovery.
137
- if (stallCount > 0) {
385
+ if (recoveryState.stallCount > 0) {
138
386
  return "stalled";
139
387
  }
140
388
  return "healthy";
@@ -143,6 +391,12 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
143
391
  * Emits a status update for this stream.
144
392
  */
145
393
  function emitStatusUpdate() {
394
+ // Do not emit after the monitor has been stopped. An in-flight async tick can resume from an await after terminateStream() has called stopMonitor() and
395
+ // emitStreamRemoved(). Without this guard, the emitStreamHealthChanged() call below would re-add the dead stream to the streamStatuses Map, creating a zombie
396
+ // entry that persists in SSE snapshots indefinitely.
397
+ if (intervalCleared) {
398
+ return;
399
+ }
146
400
  const now = Date.now();
147
401
  // Get current memory usage from the stream's HLS segment buffers.
148
402
  const entry = getStream(streamInfo.numericStreamId);
@@ -153,26 +407,31 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
153
407
  const clientSummary = getClientSummary(streamInfo.numericStreamId);
154
408
  const status = {
155
409
  bufferingDuration: bufferingStartTime ? Math.round((now - bufferingStartTime) / 1000) : null,
410
+ captureCodec: entry?.captureCodec ?? null,
156
411
  channel: streamInfo.channelName,
157
412
  clientCount: clientSummary.total,
158
413
  clients: clientSummary.clients,
159
414
  currentTime: lastVideoState?.time ?? 0,
160
415
  duration: Math.round((now - streamInfo.startTime.getTime()) / 1000),
161
- escalationLevel,
416
+ escalationLevel: recoveryState.escalationLevel,
417
+ hardwareAccelerated: entry?.hardwareAccelerated ?? false,
162
418
  health: computeHealthStatus(),
163
419
  id: streamInfo.numericStreamId,
164
420
  lastIssueTime,
165
421
  lastIssueType,
166
- lastRecoveryTime: lastRecoveryTime > 0 ? lastRecoveryTime : null,
422
+ lastRecoveryTime: recoveryState.lastRecoveryTime > 0 ? recoveryState.lastRecoveryTime : null,
167
423
  logoUrl: channelKey ? (getChannelLogo(channelKey) ?? "") : "",
168
424
  memoryBytes,
425
+ nativeBandwidth: entry?.nativeBandwidth ?? 0,
426
+ nativeResolution: entry?.nativeResolution ?? null,
169
427
  networkState: lastVideoState?.networkState ?? 0,
170
428
  pageReloadsInWindow: pageReloadTimestamps.length,
171
429
  providerName: streamInfo.providerName,
172
430
  readyState: lastVideoState?.readyState ?? 0,
173
- recoveryAttempts: totalRecoveryAttempts,
431
+ recoveryAttempts: recoveryState.totalAttempts,
174
432
  showName: getShowName(streamInfo.numericStreamId),
175
433
  startTime: streamInfo.startTime.toISOString(),
434
+ streamingMode: entry?.streamingMode ?? "capture",
176
435
  url
177
436
  };
178
437
  emitStreamHealthChanged(status);
@@ -182,7 +441,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
182
441
  * failure, and error). The flag must be reset before emitting status to prevent the monitor from getting stuck if emitStatusUpdate() throws.
183
442
  */
184
443
  function finalizeTabReplacement() {
185
- recoveryInProgress = false;
444
+ recoveryState.inProgress = false;
186
445
  emitStatusUpdate();
187
446
  }
188
447
  /**
@@ -190,13 +449,13 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
190
449
  * monitoring and continuous size monitoring.
191
450
  */
192
451
  function resetSegmentMonitoringState() {
193
- preRecoverySegmentIndex = null;
194
- segmentWaitStartTime = null;
195
- segmentProductionStalled = false;
196
- consecutiveTinySegments = 0;
197
- wasInTinySegmentState = false;
198
- lastCheckedSegmentIndex = getStream(streamInfo.numericStreamId)?.segmenter?.getSegmentIndex() ?? 0;
199
- lastSegmentAdvanceTime = Date.now();
452
+ segmentState.preRecoveryIndex = null;
453
+ segmentState.waitStartTime = null;
454
+ segmentState.productionStalled = false;
455
+ segmentState.consecutiveTinySegments = 0;
456
+ segmentState.wasInTinyState = false;
457
+ segmentState.lastCheckedIndex = getStream(streamInfo.numericStreamId)?.segmenter?.getSegmentIndex() ?? 0;
458
+ segmentState.lastSegmentAdvanceTime = Date.now();
200
459
  }
201
460
  /**
202
461
  * Resets all failure/retry counters to zero. Called after successful tab replacement or page navigation to give the stream a fresh start.
@@ -205,16 +464,38 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
205
464
  consecutiveTimeouts = 0;
206
465
  consecutiveNavigationFailures = 0;
207
466
  fullscreenReapplyCount = 0;
208
- pauseCount = 0;
467
+ recoveryState.pauseCount = 0;
468
+ recoveryState.stallCount = 0;
209
469
  videoNotFoundCount = 0;
210
- stallCount = 0;
470
+ }
471
+ /**
472
+ * Resets resolution monitoring state. Called when resolution reaches expected levels or after any recovery action that restarts ABR negotiation.
473
+ */
474
+ function resetResolutionState() {
475
+ resolutionState.consecutiveDegradedReadings = 0;
476
+ resolutionState.graceEnd = Date.now() + RESOLUTION_GRACE_PERIOD;
477
+ resolutionState.recoveryAttempt = 0;
478
+ }
479
+ /**
480
+ * Checks the page reload rate limit. Prunes expired timestamps, checks if the limit has been reached, and records the current timestamp if allowed. Callers are
481
+ * responsible for logging and handling the rate-limited case — consequences differ by context (circuit break, deferral, fallback to L2).
482
+ * @returns True if a page reload is allowed and the timestamp has been recorded, false if the rate limit has been reached.
483
+ */
484
+ function isPageReloadAllowed() {
485
+ const reloadWindow = Date.now() - CONFIG.playback.pageReloadWindow;
486
+ pageReloadTimestamps = pageReloadTimestamps.filter((ts) => ts > reloadWindow);
487
+ if (pageReloadTimestamps.length >= CONFIG.playback.maxPageReloads) {
488
+ return false;
489
+ }
490
+ pageReloadTimestamps.push(Date.now());
491
+ return true;
211
492
  }
212
493
  /**
213
494
  * Resets escalation level and related flags. Called after successful recovery to allow the stream to start from level 0 on future issues.
214
495
  */
215
496
  function resetEscalationState() {
216
- escalationLevel = 0;
217
- sourceReloadAttempted = false;
497
+ recoveryState.escalationLevel = 0;
498
+ recoveryState.sourceReloadAttempted = false;
218
499
  }
219
500
  /**
220
501
  * Sets the recovery grace period and re-minimize flag after a recovery action. The grace period prevents the monitor from immediately detecting new issues while the
@@ -223,7 +504,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
223
504
  */
224
505
  function setRecoveryGracePeriod(level) {
225
506
  pendingReMinimize = true;
226
- recoveryGraceUntil = Date.now() + recoveryGracePeriods[level];
507
+ recoveryState.graceUntil = Date.now() + recoveryGracePeriods[level];
227
508
  }
228
509
  /**
229
510
  * Handles tab replacement failure by checking the circuit breaker. If the breaker trips, terminates the stream. Returns the appropriate outcome for the caller.
@@ -256,6 +537,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
256
537
  resetRecoveryCounters();
257
538
  resetEscalationState();
258
539
  resetSegmentMonitoringState();
540
+ resetResolutionState();
259
541
  setRecoveryGracePeriod(3);
260
542
  resetCircuitBreaker(circuitBreaker);
261
543
  }
@@ -298,9 +580,9 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
298
580
  if (!onTabReplacement) {
299
581
  return { outcome: "failed" };
300
582
  }
301
- recoveryInProgress = true;
302
- totalRecoveryAttempts++;
303
- lastRecoveryTime = Date.now();
583
+ recoveryState.inProgress = true;
584
+ recoveryState.totalAttempts++;
585
+ recoveryState.lastRecoveryTime = Date.now();
304
586
  lastIssueType = issueType;
305
587
  lastIssueTime = Date.now();
306
588
  const tabRecoveryElapsed = startTimer();
@@ -390,16 +672,492 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
390
672
  return { success: false };
391
673
  }
392
674
  }
675
+ // Tick phase functions. Each implements one phase of the monitoring interval, closing over monitor state and taking per-tick computed values as parameters. Functions
676
+ // that can exit the tick return a boolean: true means the caller should return immediately, false means continue.
677
+ /**
678
+ * Handles the case where no video element was found in the current context. Applies a grace period before triggering recovery: first failure waits, second failure
679
+ * attempts frame re-search, third failure escalates to full page navigation. Distinguishes "no video element" from "video exists but not ready" (buffering).
680
+ *
681
+ * Always exits the tick — no path falls through to subsequent health checks.
682
+ * @param now - Current timestamp for timing calculations.
683
+ */
684
+ async function handleVideoNotFound(now) {
685
+ // Determine context type for diagnostic logging.
686
+ const contextType = currentContext === currentPage ? "main page" : "iframe";
687
+ const frameCount = currentPage.frames().length;
688
+ // Check video presence to distinguish between "no video" and "video exists but not ready".
689
+ let presence = null;
690
+ try {
691
+ presence = await checkVideoPresence(currentContext, selectorType);
692
+ }
693
+ catch (_error) {
694
+ // If presence check fails (context destroyed), treat as no video.
695
+ }
696
+ if (presence?.anyVideoExists && !presence.readyVideoFound) {
697
+ // Video element exists but doesn't meet readyState criteria. This is a buffering condition, not a missing video condition. Apply the normal buffering grace
698
+ // period instead of escalating to navigation.
699
+ LOG.debug("recovery:general", "Video is buffering (readyState=%s, elements=%s).", presence.maxReadyState, presence.videoCount);
700
+ // Reset video not found counter since video actually exists.
701
+ videoNotFoundCount = 0;
702
+ emitStatusUpdate();
703
+ return;
704
+ }
705
+ videoNotFoundCount++;
706
+ LOG.warn("Video element not found (attempt %s/3). Context: %s, frames: %s, videoCount: %s.", videoNotFoundCount, contextType, frameCount, presence?.videoCount ?? 0);
707
+ // Grace period: Wait for 2 consecutive failures before attempting context re-search, 3 before full navigation.
708
+ if (videoNotFoundCount < 2) {
709
+ emitStatusUpdate();
710
+ return;
711
+ }
712
+ // After 2+ failures, try re-searching frames to see if video moved to a different context.
713
+ if (videoNotFoundCount === 2) {
714
+ LOG.debug("recovery:context", "Re-searching frames for video element.");
715
+ try {
716
+ const newContext = await findVideoContext(currentPage, profile);
717
+ const validationState = await validateVideoElement(newContext, selectorType);
718
+ if (validationState.found) {
719
+ LOG.info("Video found in different context after re-search. readyState=%s.", validationState.readyState);
720
+ currentContext = newContext;
721
+ videoNotFoundCount = 0;
722
+ emitStatusUpdate();
723
+ return;
724
+ }
725
+ LOG.warn("Re-search did not find video in any frame.");
726
+ }
727
+ catch (error) {
728
+ LOG.warn("Frame re-search failed: %s.", formatError(error));
729
+ }
730
+ emitStatusUpdate();
731
+ return;
732
+ }
733
+ // After 3+ consecutive failures, escalate to full page navigation recovery.
734
+ LOG.warn("Video element not found — recovering via %s.", RECOVERY_METHODS.pageNavigation);
735
+ // Check circuit breaker for too many failures.
736
+ const cbResult = checkCircuitBreaker(circuitBreaker, now);
737
+ if (cbResult.shouldTrip) {
738
+ LOG.error("Recovery failed after %s attempts — terminating stream.", cbResult.totalCount);
739
+ clearInterval(interval);
740
+ onCircuitBreak();
741
+ return;
742
+ }
743
+ // Set escalation to level 3 to trigger page navigation. We skip lower levels since they require a video element.
744
+ // Note: Keep state updates in sync with the main recovery path in executeRecoveryAction below.
745
+ recoveryState.escalationLevel = 3;
746
+ recoveryState.lastRecoveryTime = now;
747
+ recoveryState.totalAttempts++;
748
+ pendingReMinimize = true;
749
+ recoveryState.inProgress = true;
750
+ recordRecoveryAttempt(metrics, RECOVERY_METHODS.pageNavigation);
751
+ // Check page reload limit before attempting recovery.
752
+ if (!isPageReloadAllowed()) {
753
+ LOG.error("Page navigation rate limit reached (%s in %s minutes) — cannot recover without video element.", CONFIG.playback.maxPageReloads, Math.round(CONFIG.playback.pageReloadWindow / 60000));
754
+ clearInterval(interval);
755
+ onCircuitBreak();
756
+ return;
757
+ }
758
+ const recoveryResult = await performPageNavigationRecovery();
759
+ // Page navigation disrupted the video stream. Mark a discontinuity regardless of navigation success so HLS clients resynchronize their decoders.
760
+ markStreamDiscontinuity();
761
+ // Set grace period to give page navigation time to take effect (L3 = 10 seconds).
762
+ recoveryState.graceUntil = now + recoveryGracePeriods[3];
763
+ if (recoveryResult.success && recoveryResult.newContext) {
764
+ currentContext = recoveryResult.newContext;
765
+ const duration = formatRecoveryDuration(metrics.currentRecoveryStartTime ?? now);
766
+ LOG.info("Recovered in %s via %s.", duration, RECOVERY_METHODS.pageNavigation);
767
+ recordRecoverySuccess(metrics, RECOVERY_METHODS.pageNavigation);
768
+ lastPageNavigationTime = Date.now();
769
+ resetRecoveryCounters();
770
+ resetEscalationState();
771
+ resetSegmentMonitoringState();
772
+ resetResolutionState();
773
+ }
774
+ else {
775
+ consecutiveNavigationFailures++;
776
+ LOG.warn("Page navigation did not restore playback.");
777
+ }
778
+ recoveryState.inProgress = false;
779
+ emitStatusUpdate();
780
+ }
781
+ /**
782
+ * Monitors segment production health. Performs two independent checks:
783
+ *
784
+ * 1. Post-recovery verification: After L2/L3 recovery completes and the grace period ends, verifies that segments are actually being produced. This catches the case
785
+ * where recovery reports success but the MediaRecorder/FFmpeg pipeline has silently died.
786
+ *
787
+ * 2. Continuous size monitoring: Watches segment sizes on every tick to detect spontaneous capture pipeline death. Dead pipelines produce tiny segments (18 bytes
788
+ * observed) while the video element appears healthy. Also detects segment staleness when no new segments have been produced for SEGMENT_STALENESS_TIMEOUT.
789
+ *
790
+ * @param now - Current timestamp for timing calculations.
791
+ * @param withinRecoveryGrace - Whether the monitor is within the post-recovery grace period.
792
+ * @returns True if the tick should exit (after tab replacement trigger), false to continue.
793
+ */
794
+ async function monitorSegmentHealth(now, withinRecoveryGrace) {
795
+ // Post-recovery segment verification. After recovery grace period ends, check that segments are flowing.
796
+ if ((segmentState.preRecoveryIndex !== null) && !withinRecoveryGrace) {
797
+ // Start the segment wait timer when recovery grace period ends.
798
+ segmentState.waitStartTime ??= now;
799
+ // Check if segments are flowing by comparing current index to pre-recovery index.
800
+ const entry = getStream(streamInfo.numericStreamId);
801
+ const currentIndex = entry?.segmenter?.getSegmentIndex() ?? null;
802
+ if ((currentIndex !== null) && (currentIndex > segmentState.preRecoveryIndex)) {
803
+ // Segments are flowing — recovery actually succeeded. Clear tracking state.
804
+ segmentState.preRecoveryIndex = null;
805
+ segmentState.waitStartTime = null;
806
+ segmentState.productionStalled = false;
807
+ }
808
+ else if ((now - segmentState.waitStartTime) > SEGMENT_STALL_TIMEOUT) {
809
+ // No new segments for SEGMENT_STALL_TIMEOUT after recovery grace period. The capture pipeline is dead.
810
+ LOG.warn("No segments produced for %ss after recovery — capture pipeline may have stalled.", SEGMENT_STALL_TIMEOUT / 1000);
811
+ segmentState.productionStalled = true;
812
+ }
813
+ }
814
+ // Continuous segment size monitoring. Runs on every healthy interval to detect spontaneous capture pipeline death.
815
+ const sizeCheckEntry = getStream(streamInfo.numericStreamId);
816
+ const currentSegmentIndex = sizeCheckEntry?.segmenter?.getSegmentIndex() ?? 0;
817
+ if ((currentSegmentIndex > segmentState.lastCheckedIndex) && sizeCheckEntry) {
818
+ // A new segment was produced. Update the staleness tracker and check its size.
819
+ segmentState.lastSegmentAdvanceTime = now;
820
+ const segmentSize = getLastSegmentSize(sizeCheckEntry) ?? 0;
821
+ if (segmentSize < TINY_SEGMENT_THRESHOLD) {
822
+ segmentState.consecutiveTinySegments++;
823
+ segmentState.wasInTinyState = true;
824
+ // Check track composition to determine the effective threshold. Dead capture pipelines produce audio-only segments (hasVideo=false) and always use the
825
+ // default count for fast detection. Segments with video trafs present use the provider-specific threshold, which may be higher for providers with extended
826
+ // static content (e.g., Xfinity commercial placeholders lasting several minutes).
827
+ const hasVideo = getLastSegmentHasVideo(sizeCheckEntry);
828
+ const effectiveThreshold = (hasVideo === false) ? TINY_SEGMENT_COUNT_TRIGGER : providerTinySegmentThreshold;
829
+ LOG.debug("recovery:tracks", "Below-threshold segment: %d bytes, hasVideo=%s, consecutive=%d, threshold=%d.", segmentSize, String(hasVideo), segmentState.consecutiveTinySegments, effectiveThreshold);
830
+ if (segmentState.consecutiveTinySegments >= effectiveThreshold) {
831
+ LOG.warn("Detected %d consecutive undersized segments (%dKB) — capture pipeline may have stalled.", segmentState.consecutiveTinySegments, Math.round(segmentSize / 1024));
832
+ // Trigger tab replacement if available, otherwise let circuit breaker handle it via segmentState.productionStalled.
833
+ if (onTabReplacement && !recoveryState.inProgress) {
834
+ await executeTabReplacement("tiny segments");
835
+ return true;
836
+ }
837
+ else if (!onTabReplacement) {
838
+ segmentState.productionStalled = true;
839
+ }
840
+ }
841
+ }
842
+ else {
843
+ // Valid segment size. Check for spontaneous recovery from tiny segment state.
844
+ if (segmentState.wasInTinyState) {
845
+ const hasVideo = getLastSegmentHasVideo(sizeCheckEntry);
846
+ LOG.debug("recovery:segments", "Segment production self-healed (%d bytes, hasVideo=%s).", segmentSize, String(hasVideo));
847
+ }
848
+ segmentState.consecutiveTinySegments = 0;
849
+ segmentState.wasInTinyState = false;
850
+ }
851
+ segmentState.lastCheckedIndex = currentSegmentIndex;
852
+ }
853
+ else if (sizeCheckEntry && (segmentState.lastCheckedIndex > 0) && !withinRecoveryGrace &&
854
+ ((now - segmentState.lastSegmentAdvanceTime) > SEGMENT_STALENESS_TIMEOUT)) {
855
+ /* Segment staleness detection. The segment index has not advanced for longer than SEGMENT_STALENESS_TIMEOUT. This catches the case where Chrome's
856
+ * MediaRecorder silently stops emitting data — the input Readable stream stays "open" (no end/error events) but no data events fire. The segmenter
857
+ * receives nothing, produces no new segments, and the playlist freezes at the last known sequence number. The video element on the page continues
858
+ * playing normally (currentTime advances, no errors), so all video health checks pass. Without this check, the stale playlist persists indefinitely.
859
+ *
860
+ * The sizeCheckEntry guard prevents firing on a stream that was terminated mid-tick. The segmentState.lastCheckedIndex > 0 guard ensures we don't trigger
861
+ * during stream startup before the first segment has been produced. The recovery grace guard prevents false triggering during legitimate pauses (e.g.,
862
+ * after tab replacement while the new capture pipeline is initializing).
863
+ */
864
+ LOG.warn("No new segments produced for %ss — capture pipeline may have stalled.", SEGMENT_STALENESS_TIMEOUT / 1000);
865
+ if (onTabReplacement && !recoveryState.inProgress) {
866
+ await executeTabReplacement("segment staleness");
867
+ return true;
868
+ }
869
+ else if (!onTabReplacement) {
870
+ segmentState.productionStalled = true;
871
+ }
872
+ }
873
+ return false;
874
+ }
875
+ /**
876
+ * Monitors video resolution against the configured viewport and triggers recovery for sustained ABR degradation. Uses a two-step escalation: page reload (forces ABR
877
+ * restart), then tab replacement (fresh page with new network connections). Accepts the resolution after both attempts to avoid infinite loops on legitimately
878
+ * low-resolution content. Also detects and logs resolution restoration after successful recovery.
879
+ *
880
+ * @param now - Current timestamp for timing calculations.
881
+ * @param state - Current video state with intrinsic resolution.
882
+ * @param isProgressing - Whether the video is advancing.
883
+ * @param withinRecoveryGrace - Whether the monitor is within the post-recovery grace period.
884
+ * @returns True if the tick should exit (after recovery action), false to continue.
885
+ */
886
+ async function monitorResolutionDegradation(now, state, isProgressing, withinRecoveryGrace) {
887
+ // Only check when playback is healthy, outside recovery grace, and the video has non-zero intrinsic dimensions.
888
+ if (!isProgressing || state.paused || state.error || state.ended || withinRecoveryGrace || (state.videoWidth === 0) || (state.videoHeight === 0)) {
889
+ return false;
890
+ }
891
+ const viewport = getEffectiveViewport(CONFIG);
892
+ const widthRatio = state.videoWidth / viewport.width;
893
+ const heightRatio = state.videoHeight / viewport.height;
894
+ const isDegraded = (widthRatio < RESOLUTION_RATIO_THRESHOLD) || (heightRatio < RESOLUTION_RATIO_THRESHOLD);
895
+ if (isDegraded && (now >= resolutionState.graceEnd)) {
896
+ resolutionState.consecutiveDegradedReadings++;
897
+ LOG.debug("recovery:resolution", "Video resolution: %s\u00d7%s (viewport: %s\u00d7%s, ratio: %s%%\u00d7%s%%, consecutive: %s/%s).", String(state.videoWidth), String(state.videoHeight), String(viewport.width), String(viewport.height), String(Math.round(widthRatio * 100)), String(Math.round(heightRatio * 100)), String(resolutionState.consecutiveDegradedReadings), String(RESOLUTION_DEGRADED_COUNT_THRESHOLD));
898
+ }
899
+ else {
900
+ resolutionState.consecutiveDegradedReadings = 0;
901
+ }
902
+ // Escalation step 1: page reload. Forces the provider's ABR to restart quality negotiation. Only triggers after sustained degradation
903
+ // (RESOLUTION_DEGRADED_COUNT_THRESHOLD consecutive readings) to let transient ABR dips self-heal.
904
+ if ((resolutionState.consecutiveDegradedReadings >= RESOLUTION_DEGRADED_COUNT_THRESHOLD) && (resolutionState.recoveryAttempt === 0)) {
905
+ const degradedDuration = resolutionState.consecutiveDegradedReadings * 2;
906
+ LOG.warn("Video resolution has been degraded for %ss (%s\u00d7%s in %s\u00d7%s viewport). Attempting recovery via %s.", String(degradedDuration), String(state.videoWidth), String(state.videoHeight), String(viewport.width), String(viewport.height), RECOVERY_METHODS.pageNavigation);
907
+ recoveryState.inProgress = true;
908
+ if (!isPageReloadAllowed()) {
909
+ LOG.warn("Resolution recovery deferred — page navigation rate limit reached (%s in %s minutes).", CONFIG.playback.maxPageReloads, Math.round(CONFIG.playback.pageReloadWindow / 60000));
910
+ // Defer by pushing grace end forward to avoid re-triggering every 2 seconds.
911
+ resolutionState.graceEnd = now + RESOLUTION_GRACE_PERIOD;
912
+ recoveryState.inProgress = false;
913
+ emitStatusUpdate();
914
+ return true;
915
+ }
916
+ pendingReMinimize = true;
917
+ const recoveryResult = await performPageNavigationRecovery();
918
+ markStreamDiscontinuity();
919
+ resolutionState.consecutiveDegradedReadings = 0;
920
+ resolutionState.recoveryAttempt = 1;
921
+ resolutionState.graceEnd = now + RESOLUTION_GRACE_PERIOD;
922
+ if (recoveryResult.success && recoveryResult.newContext) {
923
+ currentContext = recoveryResult.newContext;
924
+ lastPageNavigationTime = Date.now();
925
+ }
926
+ else {
927
+ LOG.warn("Resolution recovery via page reload unsuccessful.");
928
+ }
929
+ recoveryState.inProgress = false;
930
+ emitStatusUpdate();
931
+ return true;
932
+ }
933
+ // Escalation step 2: tab replacement. Creates a fresh page with new capture pipeline and network connections.
934
+ if ((resolutionState.consecutiveDegradedReadings >= RESOLUTION_DEGRADED_COUNT_THRESHOLD) && (resolutionState.recoveryAttempt === 1)) {
935
+ if (onTabReplacement) {
936
+ const degradedDuration = resolutionState.consecutiveDegradedReadings * 2;
937
+ LOG.warn("Video resolution is still degraded after %ss (%s\u00d7%s). Attempting recovery via %s.", String(degradedDuration), String(state.videoWidth), String(state.videoHeight), RECOVERY_METHODS.tabReplacement);
938
+ await executeTabReplacement("resolution degraded");
939
+ resolutionState.consecutiveDegradedReadings = 0;
940
+ resolutionState.recoveryAttempt = 2;
941
+ resolutionState.graceEnd = now + RESOLUTION_GRACE_PERIOD;
942
+ return true;
943
+ }
944
+ // Tab replacement not available — skip directly to acceptance.
945
+ resolutionState.recoveryAttempt = 2;
946
+ }
947
+ // Acceptance: resolution still degraded after both recovery attempts. Log once and stop retrying.
948
+ if ((resolutionState.consecutiveDegradedReadings >= RESOLUTION_DEGRADED_COUNT_THRESHOLD) && (resolutionState.recoveryAttempt === 2)) {
949
+ LOG.warn("Video resolution remains degraded (%s\u00d7%s in %s\u00d7%s viewport) after recovery attempts. The stream will continue at reduced quality.", String(state.videoWidth), String(state.videoHeight), String(viewport.width), String(viewport.height));
950
+ resolutionState.recoveryAttempt = 3;
951
+ }
952
+ // Resolution is good: clear tracking state so future degradation starts fresh. Use "restored" when resolution matches the viewport, "improved" when it's
953
+ // above the degradation threshold but below the viewport. Include the recovery method so this single message tells the complete story.
954
+ if (!isDegraded && (resolutionState.recoveryAttempt > 0)) {
955
+ const isFullQuality = (state.videoWidth >= viewport.width) && (state.videoHeight >= viewport.height);
956
+ const verb = isFullQuality ? "restored" : "improved";
957
+ const method = (resolutionState.recoveryAttempt === 1) ? "page reload" : "tab replacement";
958
+ LOG.info("Video resolution %s to %s\u00d7%s after %s.", verb, String(state.videoWidth), String(state.videoHeight), method);
959
+ resolutionState.consecutiveDegradedReadings = 0;
960
+ resolutionState.recoveryAttempt = 0;
961
+ resolutionState.graceEnd = 0;
962
+ }
963
+ return false;
964
+ }
965
+ /**
966
+ * Handles proactive page reload for sites with maxContinuousPlayback limits. Some streaming sites enforce a maximum continuous playback duration (e.g., NBC.com cuts
967
+ * streams after 4 hours). This function proactively reloads the page before the site's limit expires to maintain uninterrupted streaming. The reload triggers
968
+ * PROACTIVE_RELOAD_MARGIN_MS (2 minutes) before the configured limit.
969
+ *
970
+ * Only runs when playback is healthy (escalationLevel === 0), not within a recovery grace period, and progressing normally.
971
+ *
972
+ * @param now - Current timestamp for timing calculations.
973
+ * @param state - Current video state.
974
+ * @param isProgressing - Whether the video is advancing.
975
+ * @param withinRecoveryGrace - Whether the monitor is within the post-recovery grace period.
976
+ * @returns True if the tick should exit (after reload attempt or deferral), false to continue.
977
+ */
978
+ async function handleProactiveReload(now, state, isProgressing, withinRecoveryGrace) {
979
+ if ((profile.maxContinuousPlayback === null) || (recoveryState.escalationLevel !== 0) || withinRecoveryGrace || !isProgressing || state.paused || state.error ||
980
+ state.ended) {
981
+ return false;
982
+ }
983
+ const maxPlaybackMs = profile.maxContinuousPlayback * 3600000;
984
+ const elapsedMs = now - lastPageNavigationTime;
985
+ if (elapsedMs < (maxPlaybackMs - PROACTIVE_RELOAD_MARGIN_MS)) {
986
+ return false;
987
+ }
988
+ const elapsedHours = (elapsedMs / 3600000).toFixed(1);
989
+ LOG.info("Proactive reload after %sh of continuous playback (site limit: %sh). Reloading page to prevent stream cutoff.", elapsedHours, String(profile.maxContinuousPlayback));
990
+ recoveryState.inProgress = true;
991
+ // Check page reload rate limit. Proactive reload is best-effort maintenance — if the reload budget is exhausted from recent error recoveries, we gracefully
992
+ // yield. If the site eventually cuts the stream, normal error recovery handles it.
993
+ if (!isPageReloadAllowed()) {
994
+ LOG.warn("Proactive reload deferred — page navigation rate limit reached (%s in %s minutes).", CONFIG.playback.maxPageReloads, Math.round(CONFIG.playback.pageReloadWindow / 60000));
995
+ // Set a grace period to prevent this deferral from re-triggering every 2 seconds while the rate limit remains in effect. We set recoveryState.graceUntil
996
+ // directly rather than calling setRecoveryGracePeriod() because no recovery action was performed — the window state is unchanged and pendingReMinimize
997
+ // should not be set.
998
+ recoveryState.graceUntil = now + recoveryGracePeriods[3];
999
+ recoveryState.inProgress = false;
1000
+ emitStatusUpdate();
1001
+ return true;
1002
+ }
1003
+ const recoveryResult = await performPageNavigationRecovery();
1004
+ markStreamDiscontinuity();
1005
+ setRecoveryGracePeriod(3);
1006
+ if (recoveryResult.success && recoveryResult.newContext) {
1007
+ currentContext = recoveryResult.newContext;
1008
+ lastPageNavigationTime = Date.now();
1009
+ LOG.info("Proactive reload completed successfully.");
1010
+ resetRecoveryCounters();
1011
+ resetSegmentMonitoringState();
1012
+ resetResolutionState();
1013
+ }
1014
+ else {
1015
+ LOG.warn("Proactive reload unsuccessful. Will retry after recovery grace period.");
1016
+ }
1017
+ recoveryState.inProgress = false;
1018
+ emitStatusUpdate();
1019
+ return true;
1020
+ }
1021
+ /**
1022
+ * Executes recovery when the monitor has determined that recovery is needed. Handles issue-aware escalation:
1023
+ * - Paused issues try L1 (play/unmute) first since it works ~50% of the time for paused state
1024
+ * - Buffering issues skip L1 and go directly to L2 (source reload) since L1 never helps buffering
1025
+ * - If L2 has already been attempted, skip to L3 (page reload) since a second L2 always fails
1026
+ *
1027
+ * Also handles segment production stalls (direct escalation to tab replacement) and circuit breaker checks. Levels 1-2 use ensurePlayback() for in-page recovery,
1028
+ * level 3 uses performPageNavigationRecovery() for full page navigation.
1029
+ *
1030
+ * @param now - Current timestamp for timing calculations.
1031
+ * @param state - Current video state.
1032
+ * @param isProgressing - Whether the video is advancing.
1033
+ * @param isBuffering - Whether the video is actively buffering.
1034
+ * @returns True if the tick should exit (circuit breaker tripped or tab replacement triggered), false to continue.
1035
+ */
1036
+ async function executeRecoveryAction(now, state, isProgressing, isBuffering) {
1037
+ // Segment production stall handling. When segments stopped flowing after L2/L3 recovery, the capture pipeline is dead and normal recovery won't help. Skip the
1038
+ // escalation ladder and go directly to tab replacement if available.
1039
+ if (segmentState.productionStalled && onTabReplacement) {
1040
+ LOG.warn("Capture pipeline still stalled — escalating to %s.", RECOVERY_METHODS.tabReplacement);
1041
+ await executeTabReplacement("capture pipeline stalled");
1042
+ return true;
1043
+ }
1044
+ // Check circuit breaker for too many failures.
1045
+ const cbResult = checkCircuitBreaker(circuitBreaker, now);
1046
+ if (cbResult.shouldTrip) {
1047
+ const elapsedSeconds = circuitBreaker.firstFailureTime ? Math.round((now - circuitBreaker.firstFailureTime) / 1000) : 0;
1048
+ LOG.error("Recovery failed after %s attempts in %ss — terminating stream.", cbResult.totalCount, elapsedSeconds);
1049
+ clearInterval(interval);
1050
+ onCircuitBreak();
1051
+ return true;
1052
+ }
1053
+ // Issue-aware escalation. Determine the appropriate level based on the type of issue and whether source reload has already been attempted.
1054
+ const issueCategory = getIssueCategory(state, !isProgressing, isBuffering);
1055
+ let nextLevel;
1056
+ if ((issueCategory === "paused") && (recoveryState.escalationLevel === 0)) {
1057
+ // Paused issues: try L1 first (play/unmute works ~50% for paused).
1058
+ nextLevel = 1;
1059
+ }
1060
+ else if (!recoveryState.sourceReloadAttempted) {
1061
+ // First recovery attempt for buffering/other, or L1 didn't fix paused: try L2 (source reload).
1062
+ nextLevel = 2;
1063
+ }
1064
+ else {
1065
+ // Source reload already attempted: go to L3 (page reload).
1066
+ nextLevel = 3;
1067
+ }
1068
+ // Note: Keep state updates in sync with the video-not-found recovery path in handleVideoNotFound above.
1069
+ recoveryState.escalationLevel = nextLevel;
1070
+ recoveryState.lastRecoveryTime = now;
1071
+ recoveryState.totalAttempts++;
1072
+ pendingReMinimize = true;
1073
+ // Get recovery method name for logging and metrics.
1074
+ const recoveryMethod = getRecoveryMethod(recoveryState.escalationLevel);
1075
+ // Store issue type and time for UI display.
1076
+ const issueType = formatIssueType(state, !isProgressing, isBuffering);
1077
+ lastIssueType = issueType;
1078
+ lastIssueTime = now;
1079
+ // If a previous recovery was pending (L1 or L2 that didn't result in healthy playback), log that it was unsuccessful before starting the new attempt.
1080
+ if (metrics.currentRecoveryMethod !== null) {
1081
+ LOG.warn("%s did not resolve the issue — escalating to %s.", capitalize(metrics.currentRecoveryMethod), recoveryMethod);
1082
+ }
1083
+ else {
1084
+ const issueDesc = getIssueDescription(issueCategory);
1085
+ LOG.warn("Playback %s — recovering via %s.", issueDesc, recoveryMethod);
1086
+ }
1087
+ recordRecoveryAttempt(metrics, recoveryMethod);
1088
+ // For L2/L3 recovery, record the current segment index so we can verify segments are flowing after recovery completes.
1089
+ if (recoveryState.escalationLevel >= 2) {
1090
+ const entry = getStream(streamInfo.numericStreamId);
1091
+ segmentState.preRecoveryIndex = entry?.segmenter?.getSegmentIndex() ?? null;
1092
+ segmentState.waitStartTime = null;
1093
+ segmentState.productionStalled = false;
1094
+ }
1095
+ recoveryState.inProgress = true;
1096
+ try {
1097
+ // Levels 1-2: In-page recovery via ensurePlayback().
1098
+ if (recoveryState.escalationLevel <= 2) {
1099
+ await ensurePlayback(currentPage, currentContext, profile, { recoveryLevel: recoveryState.escalationLevel, skipNativeFullscreen: true });
1100
+ if (recoveryState.escalationLevel === 2) {
1101
+ recoveryState.sourceReloadAttempted = true;
1102
+ markStreamDiscontinuity();
1103
+ }
1104
+ recoveryState.graceUntil = now + recoveryGracePeriods[recoveryState.escalationLevel];
1105
+ resetResolutionState();
1106
+ }
1107
+ else {
1108
+ // Level 3: Page navigation recovery.
1109
+ // Safety check: If page navigation has failed twice consecutively, fall back to source reload.
1110
+ if (consecutiveNavigationFailures >= 2) {
1111
+ LOG.warn("Page navigation has failed %s consecutive times — falling back to source reload.", consecutiveNavigationFailures);
1112
+ recoveryState.escalationLevel = 2;
1113
+ consecutiveNavigationFailures = 0;
1114
+ recoveryState.sourceReloadAttempted = false;
1115
+ }
1116
+ else {
1117
+ if (!isPageReloadAllowed()) {
1118
+ LOG.warn("Page navigation rate limit reached (%s in %s minutes) — falling back to source reload.", CONFIG.playback.maxPageReloads, Math.round(CONFIG.playback.pageReloadWindow / 60000));
1119
+ recoveryState.escalationLevel = 2;
1120
+ recoveryState.sourceReloadAttempted = false;
1121
+ }
1122
+ else {
1123
+ const recoveryResult = await performPageNavigationRecovery();
1124
+ markStreamDiscontinuity();
1125
+ recoveryState.graceUntil = now + recoveryGracePeriods[3];
1126
+ if (recoveryResult.success && recoveryResult.newContext) {
1127
+ currentContext = recoveryResult.newContext;
1128
+ const duration = formatRecoveryDuration(metrics.currentRecoveryStartTime ?? now);
1129
+ LOG.info("Recovered in %s via %s.", duration, RECOVERY_METHODS.pageNavigation);
1130
+ recordRecoverySuccess(metrics, RECOVERY_METHODS.pageNavigation);
1131
+ lastPageNavigationTime = Date.now();
1132
+ resetRecoveryCounters();
1133
+ resetEscalationState();
1134
+ resetSegmentMonitoringState();
1135
+ resetResolutionState();
1136
+ }
1137
+ else {
1138
+ consecutiveNavigationFailures++;
1139
+ LOG.warn("Page navigation did not restore playback (attempt %s/2).", consecutiveNavigationFailures);
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+ }
1145
+ catch (error) {
1146
+ LOG.warn("Recovery via %s failed: %s.", getRecoveryMethod(recoveryState.escalationLevel), formatError(error));
1147
+ }
1148
+ recoveryState.inProgress = false;
1149
+ return false;
1150
+ }
393
1151
  /* Main monitoring interval. This runs every MONITOR_INTERVAL milliseconds to check video state and trigger recovery when needed.
394
1152
  *
395
1153
  * IMPORTANT: Early returns must call emitStatusUpdate() before returning (except when the stream is terminating, e.g., page closed or circuit breaker tripped). This
396
1154
  * ensures SSE clients always have current status data (duration, memory, health) even during recovery, buffering, or video search periods. Without this, the
397
1155
  * streamStatuses map becomes stale and new SSE connections receive outdated snapshots.
398
1156
  *
399
- * CHECK ORDER MATTERS: The recoveryInProgress check must come BEFORE the currentPage.isClosed() check. During tab replacement, the old page is intentionally closed
400
- * while the handler creates a new page. If we check isClosed() first, we would terminate the interval while recovery is still in progress, causing status updates to
401
- * stop permanently. The sequence is: (1) intervalCleared for explicit cleanup, (2) recoveryInProgress to continue during recovery, (3) isClosed() for unexpected
402
- * page termination outside of recovery.
1157
+ * CHECK ORDER MATTERS: The recoveryState.inProgress check must come BEFORE the currentPage.isClosed() check. During tab replacement, the old page is intentionally
1158
+ * closed while the handler creates a new page. If we check isClosed() first, we would terminate the interval while recovery is still in progress, causing status
1159
+ * updates to stop permanently. The sequence is: (1) intervalCleared for explicit cleanup, (2) recoveryState.inProgress to continue during recovery, (3) isClosed()
1160
+ * for unexpected page termination outside of recovery.
403
1161
  */
404
1162
  const interval = setInterval(() => {
405
1163
  // Stop monitoring if cleanup was requested.
@@ -409,7 +1167,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
409
1167
  }
410
1168
  // Skip health checks if a recovery operation is in progress. During tab replacement, the old page will be closed but we must keep the interval running until the
411
1169
  // new page is assigned. Emit status so SSE clients see current state (health, duration, memory) even during recovery.
412
- if (recoveryInProgress) {
1170
+ if (recoveryState.inProgress) {
413
1171
  emitStatusUpdate();
414
1172
  return;
415
1173
  }
@@ -423,6 +1181,13 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
423
1181
  emitStatusUpdate();
424
1182
  return;
425
1183
  }
1184
+ // For native streaming mode, monitor segment delivery health instead of video element state. We check the registry on each tick rather than caching the mode at
1185
+ // startup because the streaming mode is set after the monitor starts (native streaming is attempted after setupStream returns).
1186
+ const nativeEntry = getStream(streamInfo.numericStreamId);
1187
+ if (nativeEntry?.streamingMode === "native") {
1188
+ checkNativeStreamHealth(nativeEntry);
1189
+ return;
1190
+ }
426
1191
  // Re-establish stream context for this interval tick. AsyncLocalStorage context is lost when entering setInterval callbacks.
427
1192
  runWithStreamContext(streamContext, async () => {
428
1193
  try {
@@ -476,121 +1241,9 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
476
1241
  }
477
1242
  // If re-search failed, let the normal "video not found" logic handle it.
478
1243
  }
479
- /* If no video element found, apply a grace period before triggering recovery. The video may be temporarily unavailable due to:
480
- * - readyState fluctuations (selectReadyVideo finds no video with readyState >= 3)
481
- * - Frame detachment/reattachment during page updates
482
- * - Momentary DOM changes during ad transitions
483
- *
484
- * We distinguish between "no video element exists" and "video exists but not ready". The latter is treated as buffering and given more time.
485
- */
1244
+ // Video not found: apply grace period, attempt frame re-search, and escalate to page navigation recovery. Always exits the tick.
486
1245
  if (!state) {
487
- // Determine context type for diagnostic logging.
488
- const contextType = currentContext === currentPage ? "main page" : "iframe";
489
- const frameCount = currentPage.frames().length;
490
- // Check video presence to distinguish between "no video" and "video exists but not ready".
491
- let presence = null;
492
- try {
493
- presence = await checkVideoPresence(currentContext, selectorType);
494
- }
495
- catch (_error) {
496
- // If presence check fails (context destroyed), treat as no video.
497
- }
498
- if (presence?.anyVideoExists && !presence.readyVideoFound) {
499
- // Video element exists but doesn't meet readyState criteria. This is a buffering condition, not a missing video condition.
500
- // Apply the normal buffering grace period instead of escalating to navigation.
501
- LOG.debug("recovery", "Video is buffering (readyState=%s, elements=%s).", presence.maxReadyState, presence.videoCount);
502
- // Reset video not found counter since video actually exists.
503
- videoNotFoundCount = 0;
504
- // Emit status so SSE clients see current state even during this buffering condition.
505
- emitStatusUpdate();
506
- // Let the normal buffering detection handle this on subsequent checks.
507
- return;
508
- }
509
- videoNotFoundCount++;
510
- LOG.warn("Video element not found (attempt %s/3). Context: %s, frames: %s, videoCount: %s.", videoNotFoundCount, contextType, frameCount, presence?.videoCount ?? 0);
511
- // Grace period: Wait for 2 consecutive failures before attempting context re-search, 3 before full navigation.
512
- if (videoNotFoundCount < 2) {
513
- // First failure - just log and wait for next check. Emit status so SSE clients stay current.
514
- emitStatusUpdate();
515
- return;
516
- }
517
- // After 2+ failures, try re-searching frames to see if video moved to a different context.
518
- if (videoNotFoundCount === 2) {
519
- LOG.debug("recovery:context", "Re-searching frames for video element.");
520
- try {
521
- const newContext = await findVideoContext(currentPage, profile);
522
- const validationState = await validateVideoElement(newContext, selectorType);
523
- if (validationState.found) {
524
- LOG.info("Video found in different context after re-search. readyState=%s.", validationState.readyState);
525
- currentContext = newContext;
526
- videoNotFoundCount = 0;
527
- // Emit status so SSE clients see current state.
528
- emitStatusUpdate();
529
- return;
530
- }
531
- LOG.warn("Re-search did not find video in any frame.");
532
- }
533
- catch (error) {
534
- LOG.warn("Frame re-search failed: %s.", formatError(error));
535
- }
536
- // Emit status so SSE clients stay current during video search.
537
- emitStatusUpdate();
538
- return;
539
- }
540
- // After 3+ consecutive failures, escalate to full page navigation recovery.
541
- LOG.warn("Video element not found — recovering via %s.", RECOVERY_METHODS.pageNavigation);
542
- // Check circuit breaker for too many failures.
543
- const cbResult = checkCircuitBreaker(circuitBreaker, now);
544
- if (cbResult.shouldTrip) {
545
- LOG.error("Recovery failed after %s attempts — terminating stream.", cbResult.totalCount);
546
- clearInterval(interval);
547
- onCircuitBreak();
548
- return;
549
- }
550
- // Set escalation to level 3 to trigger page navigation. We skip lower levels since they require a video element.
551
- // Note: Keep state updates in sync with the main recovery path in the needsRecovery block below.
552
- escalationLevel = 3;
553
- lastRecoveryTime = now;
554
- totalRecoveryAttempts++;
555
- pendingReMinimize = true;
556
- recoveryInProgress = true;
557
- recordRecoveryAttempt(metrics, RECOVERY_METHODS.pageNavigation);
558
- // Check page reload limit before attempting recovery.
559
- const reloadWindow = now - CONFIG.playback.pageReloadWindow;
560
- pageReloadTimestamps = pageReloadTimestamps.filter((ts) => ts > reloadWindow);
561
- if (pageReloadTimestamps.length >= CONFIG.playback.maxPageReloads) {
562
- LOG.error("Page navigation rate limit reached (%s in %s minutes) — cannot recover without video element.", CONFIG.playback.maxPageReloads, Math.round(CONFIG.playback.pageReloadWindow / 60000));
563
- clearInterval(interval);
564
- onCircuitBreak();
565
- return;
566
- }
567
- pageReloadTimestamps.push(now);
568
- // Use the unified recovery function with validation.
569
- const recoveryResult = await performPageNavigationRecovery();
570
- // Page navigation disrupted the video stream. Mark a discontinuity regardless of navigation success so HLS clients resynchronize their decoders.
571
- markStreamDiscontinuity();
572
- // Set grace period to give page navigation time to take effect (L3 = 10 seconds).
573
- recoveryGraceUntil = now + recoveryGracePeriods[3];
574
- if (recoveryResult.success && recoveryResult.newContext) {
575
- // Update the context reference for subsequent monitor checks (only after validation succeeds).
576
- currentContext = recoveryResult.newContext;
577
- // Log success with timing.
578
- const duration = formatRecoveryDuration(metrics.currentRecoveryStartTime ?? now);
579
- LOG.info("Recovered in %s via %s.", duration, RECOVERY_METHODS.pageNavigation);
580
- recordRecoverySuccess(metrics, RECOVERY_METHODS.pageNavigation);
581
- // Reset state after successful page navigation recovery.
582
- lastPageNavigationTime = Date.now();
583
- resetRecoveryCounters();
584
- resetEscalationState();
585
- resetSegmentMonitoringState();
586
- }
587
- else {
588
- consecutiveNavigationFailures++;
589
- LOG.warn("Page navigation did not restore playback.");
590
- }
591
- recoveryInProgress = false;
592
- // Emit status so SSE clients see the recovery result.
593
- emitStatusUpdate();
1246
+ await handleVideoNotFound(now);
594
1247
  return;
595
1248
  }
596
1249
  // Video was found - reset the not found counter, timeout counter, and save state for status reporting.
@@ -625,93 +1278,16 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
625
1278
  // Check if we're within the buffering grace period (recently started buffering and haven't exceeded the threshold).
626
1279
  const withinBufferingGrace = isBuffering && bufferingStartTime && ((now - bufferingStartTime) < CONFIG.playback.bufferingGracePeriod);
627
1280
  // Check if we're within the recovery grace period (recently performed a recovery action and waiting for it to take effect).
628
- const withinRecoveryGrace = now < recoveryGraceUntil;
629
- /* Segment production monitoring. After L2/L3 recovery completes (grace period ends), we verify that segments are actually being produced. If recovery reported
630
- * success but the capture pipeline is dead (MediaRecorder stopped producing data, FFmpeg stdin idle), segments will stop flowing while the video element
631
- * appears healthy. This catches the 20+ minute freeze bug where PrismCast reports "Recovered" but Channels DVR receives no data.
632
- */
633
- if ((preRecoverySegmentIndex !== null) && !withinRecoveryGrace) {
634
- // Start the segment wait timer when recovery grace period ends.
635
- segmentWaitStartTime ??= now;
636
- // Check if segments are flowing by comparing current index to pre-recovery index.
637
- const entry = getStream(streamInfo.numericStreamId);
638
- const currentSegmentIndex = entry?.segmenter?.getSegmentIndex() ?? null;
639
- if ((currentSegmentIndex !== null) && (currentSegmentIndex > preRecoverySegmentIndex)) {
640
- // Segments are flowing - recovery actually succeeded. Clear tracking state.
641
- preRecoverySegmentIndex = null;
642
- segmentWaitStartTime = null;
643
- segmentProductionStalled = false;
644
- }
645
- else if ((now - segmentWaitStartTime) > SEGMENT_STALL_TIMEOUT) {
646
- // No new segments for SEGMENT_STALL_TIMEOUT after recovery grace period. The capture pipeline is dead.
647
- LOG.warn("No segments produced for %ss after recovery — capture pipeline is not responding.", SEGMENT_STALL_TIMEOUT / 1000);
648
- segmentProductionStalled = true;
649
- }
650
- }
651
- /* Continuous segment size monitoring. Runs on every healthy interval to detect spontaneous capture pipeline death (no preceding recovery event). Dead pipelines
652
- * produce tiny segments (18 bytes observed) while the video element appears healthy. This catches failures that post-recovery index monitoring misses because
653
- * there's no recovery to trigger monitoring.
654
- */
655
- const sizeCheckEntry = getStream(streamInfo.numericStreamId);
656
- const currentSegmentIndex = sizeCheckEntry?.segmenter?.getSegmentIndex() ?? 0;
657
- if ((currentSegmentIndex > lastCheckedSegmentIndex) && sizeCheckEntry) {
658
- // A new segment was produced. Update the staleness tracker and check its size.
659
- lastSegmentAdvanceTime = now;
660
- const segmentSize = getLastSegmentSize(sizeCheckEntry) ?? 0;
661
- if (segmentSize < TINY_SEGMENT_THRESHOLD) {
662
- consecutiveTinySegments++;
663
- wasInTinySegmentState = true;
664
- if (consecutiveTinySegments >= TINY_SEGMENT_COUNT_TRIGGER) {
665
- LOG.warn("Detected %d consecutive tiny segments (%d bytes) — capture pipeline is not responding.", consecutiveTinySegments, segmentSize);
666
- // Trigger tab replacement if available, otherwise let circuit breaker handle it via segmentProductionStalled. Return unconditionally after tab
667
- // replacement (matching stalled-capture and unresponsive-tab triggers) to avoid falling through the rest of the tick with stale pre-replacement state.
668
- if (onTabReplacement && !recoveryInProgress) {
669
- await executeTabReplacement("tiny segments");
670
- return;
671
- }
672
- else if (!onTabReplacement) {
673
- // No tab replacement callback - set stalled flag for circuit breaker.
674
- segmentProductionStalled = true;
675
- }
676
- }
677
- }
678
- else {
679
- // Valid segment size. Check for spontaneous recovery from tiny segment state. We don't mark discontinuity here - only tab replacement marks discontinuity.
680
- // Self-healing may be transient and not require decoder reset.
681
- if (wasInTinySegmentState) {
682
- LOG.debug("recovery:segments", "Segment production self-healed (%d bytes).", segmentSize);
683
- }
684
- // Reset tiny segment tracking.
685
- consecutiveTinySegments = 0;
686
- wasInTinySegmentState = false;
687
- }
688
- lastCheckedSegmentIndex = currentSegmentIndex;
689
- }
690
- else if (sizeCheckEntry && (lastCheckedSegmentIndex > 0) && !withinRecoveryGrace && ((now - lastSegmentAdvanceTime) > SEGMENT_STALENESS_TIMEOUT)) {
691
- /* Segment staleness detection. The segment index has not advanced for longer than SEGMENT_STALENESS_TIMEOUT. This catches the case where Chrome's
692
- * MediaRecorder silently stops emitting data — the input Readable stream stays "open" (no end/error events) but no data events fire. The segmenter
693
- * receives nothing, produces no new segments, and the playlist freezes at the last known sequence number. The video element on the page continues
694
- * playing normally (currentTime advances, no errors), so all video health checks pass. Without this check, the stale playlist persists indefinitely.
695
- *
696
- * The sizeCheckEntry guard prevents firing on a stream that was terminated mid-tick. The lastCheckedSegmentIndex > 0 guard ensures we don't trigger
697
- * during stream startup before the first segment has been produced. The recovery grace guard prevents false triggering during legitimate pauses (e.g.,
698
- * after tab replacement while the new capture pipeline is initializing).
699
- */
700
- LOG.warn("No new segments produced for %ss — capture pipeline may have stalled.", SEGMENT_STALENESS_TIMEOUT / 1000);
701
- if (onTabReplacement && !recoveryInProgress) {
702
- await executeTabReplacement("segment staleness");
703
- return;
704
- }
705
- else if (!onTabReplacement) {
706
- // No tab replacement callback - set stalled flag for circuit breaker.
707
- segmentProductionStalled = true;
708
- }
1281
+ const withinRecoveryGrace = now < recoveryState.graceUntil;
1282
+ // Segment production monitoring: post-recovery verification and continuous size/staleness checks.
1283
+ if (await monitorSegmentHealth(now, withinRecoveryGrace)) {
1284
+ return;
709
1285
  }
710
1286
  /* Re-minimize check. After recovery, the browser window may have been un-minimized by fullscreen actions. As soon as the stream is healthy (progressing without
711
1287
  * issues), we re-minimize to reduce GPU usage.
712
1288
  */
713
1289
  if (pendingReMinimize && isProgressing && !state.paused && !state.error && !state.ended) {
714
- LOG.debug("recovery", "Re-minimizing browser window after successful recovery.");
1290
+ LOG.debug("recovery:general", "Re-minimizing browser window after successful recovery.");
715
1291
  pendingReMinimize = false;
716
1292
  await resizeAndMinimizeWindow(currentPage, true);
717
1293
  }
@@ -743,23 +1319,28 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
743
1319
  fullscreenReapplyCount = 0;
744
1320
  }
745
1321
  }
746
- /* Stall counter management. We increment stallCount when the video is not progressing and not within buffering grace. We reset to 0 when progression resumes.
747
- * This hysteresis prevents reacting to single-frame hiccups.
1322
+ // Resolution degradation detection: two-step recovery (page reload, tab replacement) for sustained ABR degradation.
1323
+ if (await monitorResolutionDegradation(now, state, isProgressing, withinRecoveryGrace)) {
1324
+ return;
1325
+ }
1326
+ /* Stall counter management. We increment recoveryState.stallCount when the video is not progressing and not within buffering grace. We reset to 0 when
1327
+ * progression resumes. This hysteresis prevents reacting to single-frame hiccups.
748
1328
  */
749
1329
  if (!isProgressing && !withinBufferingGrace) {
750
- stallCount++;
1330
+ recoveryState.stallCount++;
751
1331
  }
752
1332
  else if (isProgressing) {
753
- stallCount = 0;
1333
+ recoveryState.stallCount = 0;
754
1334
  }
755
- /* Pause counter management. We increment pauseCount when video.paused is true and reset when it clears. This provides the same hysteresis as stall detection,
756
- * filtering out transient rebuffer pauses (where the player briefly pauses to refill its buffer) while still catching genuine persistent pauses.
1335
+ /* Pause counter management. We increment recoveryState.pauseCount when video.paused is true and reset when it clears. This provides the same hysteresis as
1336
+ * stall detection, filtering out transient rebuffer pauses (where the player briefly pauses to refill its buffer) while still catching genuine persistent
1337
+ * pauses.
757
1338
  */
758
1339
  if (state.paused) {
759
- pauseCount++;
1340
+ recoveryState.pauseCount++;
760
1341
  }
761
1342
  else {
762
- pauseCount = 0;
1343
+ recoveryState.pauseCount = 0;
763
1344
  }
764
1345
  /* Recovery decision. We trigger recovery when any of these conditions are met AND we're not within the recovery grace period:
765
1346
  * - Video has an error state
@@ -769,9 +1350,9 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
769
1350
  * - Segment production has stalled after recovery (capture pipeline dead)
770
1351
  */
771
1352
  const needsRecovery = !withinRecoveryGrace && (state.error || state.ended ||
772
- (state.paused && !withinBufferingGrace && (pauseCount > CONFIG.playback.stallCountThreshold)) ||
773
- (!isProgressing && !withinBufferingGrace && (stallCount > CONFIG.playback.stallCountThreshold)) ||
774
- segmentProductionStalled);
1353
+ (state.paused && !withinBufferingGrace && (recoveryState.pauseCount > CONFIG.playback.stallCountThreshold)) ||
1354
+ (!isProgressing && !withinBufferingGrace && (recoveryState.stallCount > CONFIG.playback.stallCountThreshold)) ||
1355
+ segmentState.productionStalled);
775
1356
  /* Escalation reset. After sustained healthy playback (SUSTAINED_PLAYBACK_REQUIRED, default 60 seconds), we reset the escalation level and circuit breaker.
776
1357
  * This allows a stream that recovered to start fresh, rather than immediately escalating to aggressive recovery on the next issue.
777
1358
  */
@@ -782,8 +1363,8 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
782
1363
  LOG.info("Recovered in %s via %s.", duration, metrics.currentRecoveryMethod);
783
1364
  recordRecoverySuccess(metrics, metrics.currentRecoveryMethod);
784
1365
  }
785
- const healthyDuration = now - lastRecoveryTime;
786
- if ((escalationLevel > 0) && (healthyDuration > CONFIG.playback.sustainedPlaybackRequired)) {
1366
+ const healthyDuration = now - recoveryState.lastRecoveryTime;
1367
+ if ((recoveryState.escalationLevel > 0) && (healthyDuration > CONFIG.playback.sustainedPlaybackRequired)) {
787
1368
  // Clear buffering state. The bufferingStartTime may persist through recovery cycles due to networkState === 2 (NETWORK_LOADING) being true for live streams
788
1369
  // even during healthy playback. Since we have confirmed 60 seconds of progression, the stream is definitively not buffering.
789
1370
  bufferingStartTime = null;
@@ -793,208 +1374,15 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
793
1374
  resetCircuitBreaker(circuitBreaker);
794
1375
  }
795
1376
  }
796
- /* Proactive page reload. Some streaming sites enforce a maximum continuous playback duration (e.g., NBC.com cuts streams after 4 hours). When a domain
797
- * configures maxContinuousPlayback, we proactively reload the page before the site's limit expires to maintain uninterrupted streaming. The reload triggers
798
- * PROACTIVE_RELOAD_MARGIN_MS (2 minutes) before the configured limit, giving enough time for page navigation and video reinitialization.
799
- *
800
- * This check runs only when playback is healthy (escalationLevel === 0), not within a recovery grace period, and progressing normally. If recovery is already
801
- * in progress, the ongoing recovery will eventually perform a page navigation if needed. The page reload rate limit is also checked to avoid consuming reload
802
- * budget that error recovery needs. The timer resets after any successful full page navigation (proactive or recovery-triggered).
803
- */
804
- if ((profile.maxContinuousPlayback !== null) && (escalationLevel === 0) && !withinRecoveryGrace && isProgressing && !state.paused && !state.error &&
805
- !state.ended) {
806
- const maxPlaybackMs = profile.maxContinuousPlayback * 3600000;
807
- const elapsedMs = now - lastPageNavigationTime;
808
- if (elapsedMs >= (maxPlaybackMs - PROACTIVE_RELOAD_MARGIN_MS)) {
809
- const elapsedHours = (elapsedMs / 3600000).toFixed(1);
810
- LOG.info("Proactive reload after %sh of continuous playback (site limit: %sh). Reloading page to prevent stream cutoff.", elapsedHours, String(profile.maxContinuousPlayback));
811
- recoveryInProgress = true;
812
- // Check page reload rate limit before attempting. Proactive reload is best-effort maintenance — if the reload budget is exhausted from recent error
813
- // recoveries, we gracefully yield. If the site eventually cuts the stream, normal error recovery handles it.
814
- const reloadWindow = now - CONFIG.playback.pageReloadWindow;
815
- pageReloadTimestamps = pageReloadTimestamps.filter((ts) => ts > reloadWindow);
816
- if (pageReloadTimestamps.length >= CONFIG.playback.maxPageReloads) {
817
- LOG.warn("Proactive reload deferred — page navigation rate limit reached (%s in %s minutes).", CONFIG.playback.maxPageReloads, Math.round(CONFIG.playback.pageReloadWindow / 60000));
818
- // Set a grace period to prevent this deferral from re-triggering every 2 seconds while the rate limit remains in effect. The 10-second L3 grace
819
- // period spaces out re-checks, and the rate-limit window (default 15 minutes) will eventually expire old timestamps to allow the proactive reload. We
820
- // set recoveryGraceUntil directly rather than calling setRecoveryGracePeriod() because no recovery action was performed — the window state is unchanged
821
- // and pendingReMinimize should not be set.
822
- recoveryGraceUntil = now + recoveryGracePeriods[3];
823
- recoveryInProgress = false;
824
- emitStatusUpdate();
825
- return;
826
- }
827
- pageReloadTimestamps.push(now);
828
- const recoveryResult = await performPageNavigationRecovery();
829
- // Page navigation disrupted the video stream. Mark a discontinuity so HLS clients resynchronize their decoders.
830
- markStreamDiscontinuity();
831
- setRecoveryGracePeriod(3);
832
- if (recoveryResult.success && recoveryResult.newContext) {
833
- currentContext = recoveryResult.newContext;
834
- lastPageNavigationTime = Date.now();
835
- LOG.info("Proactive reload completed successfully.");
836
- resetRecoveryCounters();
837
- resetSegmentMonitoringState();
838
- }
839
- else {
840
- LOG.warn("Proactive reload unsuccessful. Will retry after recovery grace period.");
841
- }
842
- recoveryInProgress = false;
843
- emitStatusUpdate();
844
- return;
845
- }
1377
+ // Proactive page reload for sites with maxContinuousPlayback limits.
1378
+ if (await handleProactiveReload(now, state, isProgressing, withinRecoveryGrace)) {
1379
+ return;
846
1380
  }
847
- /* Recovery execution. When recovery is needed, we update circuit breaker state, determine the appropriate recovery level based on issue type and history, and
848
- * execute the recovery action. The recovery system is issue-aware:
849
- * - Paused issues try L1 (play/unmute) first since it works ~50% of the time for paused state
850
- * - Buffering issues skip L1 and go directly to L2 (source reload) since L1 never helps buffering
851
- * - If L2 has already been attempted, skip to L3 (page reload) since a second L2 always fails
852
- */
1381
+ // Recovery execution: issue-aware escalation (L1 play/unmute, L2 source reload, L3 page navigation) with circuit breaker.
853
1382
  if (needsRecovery) {
854
- /* Segment production stall handling. When we detect that segments stopped flowing after L2/L3 recovery, the capture pipeline is dead and normal recovery
855
- * won't help. We skip the escalation ladder and go directly to tab replacement if available.
856
- */
857
- if (segmentProductionStalled && onTabReplacement) {
858
- LOG.warn("Capture pipeline still stalled — escalating to %s.", RECOVERY_METHODS.tabReplacement);
859
- await executeTabReplacement("capture pipeline stalled");
860
- return;
861
- }
862
- // Check circuit breaker for too many failures. The helper handles incrementing, window checks, and resetting if outside the window.
863
- const cbResult = checkCircuitBreaker(circuitBreaker, now);
864
- if (cbResult.shouldTrip) {
865
- const elapsedSeconds = circuitBreaker.firstFailureTime ? Math.round((now - circuitBreaker.firstFailureTime) / 1000) : 0;
866
- LOG.error("Recovery failed after %s attempts in %ss — terminating stream.", cbResult.totalCount, elapsedSeconds);
867
- clearInterval(interval);
868
- onCircuitBreak();
1383
+ if (await executeRecoveryAction(now, state, isProgressing, isBuffering)) {
869
1384
  return;
870
1385
  }
871
- /* Issue-aware escalation. Instead of blindly incrementing the level, we determine the appropriate level based on:
872
- * 1. The type of issue (paused vs buffering vs other)
873
- * 2. Whether source reload (L2) has already been attempted in this page session
874
- * 3. The current escalation level
875
- *
876
- * Levels:
877
- * - Level 1: Basic play/unmute - only for paused issues
878
- * - Level 2: Reload video source - for buffering/other issues, or when L1 fails
879
- * - Level 3: Page navigation - when L2 fails or has already been attempted
880
- */
881
- const issueCategory = getIssueCategory(state, !isProgressing, isBuffering);
882
- let nextLevel;
883
- if ((issueCategory === "paused") && (escalationLevel === 0)) {
884
- // Paused issues: try L1 first (play/unmute works ~50% for paused).
885
- nextLevel = 1;
886
- }
887
- else if (!sourceReloadAttempted) {
888
- // First recovery attempt for buffering/other, or L1 didn't fix paused: try L2 (source reload).
889
- nextLevel = 2;
890
- }
891
- else {
892
- // Source reload already attempted: go to L3 (page reload).
893
- nextLevel = 3;
894
- }
895
- // Note: Keep state updates in sync with the video-not-found recovery path above.
896
- escalationLevel = nextLevel;
897
- lastRecoveryTime = now;
898
- totalRecoveryAttempts++;
899
- pendingReMinimize = true;
900
- // Get recovery method name for logging and metrics.
901
- const recoveryMethod = getRecoveryMethod(escalationLevel);
902
- // Store issue type and time for UI display.
903
- const issueType = formatIssueType(state, !isProgressing, isBuffering);
904
- lastIssueType = issueType;
905
- lastIssueTime = now;
906
- // If a previous recovery was pending (L1 or L2 that didn't result in healthy playback), log that it was unsuccessful before starting the new attempt.
907
- if (metrics.currentRecoveryMethod !== null) {
908
- LOG.warn("%s did not resolve the issue — escalating to %s.", capitalize(metrics.currentRecoveryMethod), recoveryMethod);
909
- }
910
- else {
911
- // First recovery attempt - log with issue description.
912
- const issueDesc = getIssueDescription(issueCategory);
913
- LOG.warn("Playback %s — recovering via %s.", issueDesc, recoveryMethod);
914
- }
915
- // Record this recovery attempt in metrics.
916
- recordRecoveryAttempt(metrics, recoveryMethod);
917
- // For L2/L3 recovery, record the current segment index so we can verify segments are flowing after recovery completes.
918
- if (escalationLevel >= 2) {
919
- const entry = getStream(streamInfo.numericStreamId);
920
- preRecoverySegmentIndex = entry?.segmenter?.getSegmentIndex() ?? null;
921
- segmentWaitStartTime = null; // Will be set after recovery grace period ends.
922
- segmentProductionStalled = false;
923
- }
924
- // Mark recovery in progress to prevent overlapping recovery attempts.
925
- recoveryInProgress = true;
926
- try {
927
- /* Levels 1-2: In-page recovery. These levels are handled by ensurePlayback() which performs recovery actions without navigating the page.
928
- */
929
- if (escalationLevel <= 2) {
930
- await ensurePlayback(currentPage, currentContext, profile, { recoveryLevel: escalationLevel, skipNativeFullscreen: true });
931
- // Track that source reload was attempted so we skip directly to L3 next time.
932
- if (escalationLevel === 2) {
933
- sourceReloadAttempted = true;
934
- // The source reload disrupted the video stream. Mark a discontinuity so HLS clients resynchronize their decoders.
935
- markStreamDiscontinuity();
936
- }
937
- // Set grace period to give this recovery level time to take effect before the next check.
938
- recoveryGraceUntil = now + recoveryGracePeriods[escalationLevel];
939
- }
940
- else {
941
- /* Level 3: Page navigation recovery. This is the most aggressive recovery - we navigate to the URL again and reinitialize everything.
942
- */
943
- // Safety check: If page navigation has failed twice consecutively, fall back to source reload. This prevents getting stuck in a loop when navigation
944
- // itself is broken (e.g., network issues, site blocking).
945
- if (consecutiveNavigationFailures >= 2) {
946
- LOG.warn("Page navigation has failed %s consecutive times — falling back to source reload.", consecutiveNavigationFailures);
947
- escalationLevel = 2;
948
- consecutiveNavigationFailures = 0;
949
- // Reset source reload tracking so the fallback L2 gets a fair chance. Without this, the next recovery cycle would skip L2 and try L3 again.
950
- sourceReloadAttempted = false;
951
- }
952
- else {
953
- // Check page reload limit to prevent excessive navigations. We allow MAX_PAGE_RELOADS within PAGE_RELOAD_WINDOW.
954
- const reloadWindow = now - CONFIG.playback.pageReloadWindow;
955
- // Prune old timestamps outside the window.
956
- pageReloadTimestamps = pageReloadTimestamps.filter((ts) => {
957
- return ts > reloadWindow;
958
- });
959
- if (pageReloadTimestamps.length >= CONFIG.playback.maxPageReloads) {
960
- LOG.warn("Page navigation rate limit reached (%s in %s minutes) — falling back to source reload.", CONFIG.playback.maxPageReloads, Math.round(CONFIG.playback.pageReloadWindow / 60000));
961
- escalationLevel = 2;
962
- // Reset source reload tracking so the fallback L2 gets a fair chance.
963
- sourceReloadAttempted = false;
964
- }
965
- else {
966
- pageReloadTimestamps.push(now);
967
- // Use the unified recovery function with validation.
968
- const recoveryResult = await performPageNavigationRecovery();
969
- // Page navigation disrupted the video stream. Mark a discontinuity regardless of navigation success so HLS clients resynchronize their decoders.
970
- markStreamDiscontinuity();
971
- // Set grace period to give page navigation time to take effect (L3 = 10 seconds).
972
- recoveryGraceUntil = now + recoveryGracePeriods[3];
973
- if (recoveryResult.success && recoveryResult.newContext) {
974
- // Update the context reference to the new context (only after validation succeeds).
975
- currentContext = recoveryResult.newContext;
976
- // Log success with timing.
977
- const duration = formatRecoveryDuration(metrics.currentRecoveryStartTime ?? now);
978
- LOG.info("Recovered in %s via %s.", duration, RECOVERY_METHODS.pageNavigation);
979
- recordRecoverySuccess(metrics, RECOVERY_METHODS.pageNavigation);
980
- // Reset state after successful page navigation recovery.
981
- lastPageNavigationTime = Date.now();
982
- resetRecoveryCounters();
983
- resetEscalationState();
984
- resetSegmentMonitoringState();
985
- }
986
- else {
987
- consecutiveNavigationFailures++;
988
- LOG.warn("Page navigation did not restore playback (attempt %s/2).", consecutiveNavigationFailures);
989
- }
990
- }
991
- }
992
- }
993
- }
994
- catch (error) {
995
- LOG.warn("Recovery via %s failed: %s.", getRecoveryMethod(escalationLevel), formatError(error));
996
- }
997
- recoveryInProgress = false;
998
1386
  }
999
1387
  // Update lastTime for the next stall check.
1000
1388
  lastTime = state.time;
@@ -1002,7 +1390,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
1002
1390
  emitStatusUpdate();
1003
1391
  }
1004
1392
  catch (error) {
1005
- recoveryInProgress = false;
1393
+ recoveryState.inProgress = false;
1006
1394
  // If the session or page was closed, stop monitoring gracefully.
1007
1395
  if (isSessionClosedError(error) || currentPage.isClosed()) {
1008
1396
  clearInterval(interval);
@@ -1028,7 +1416,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
1028
1416
  // Log abort errors at debug level since they're expected during stream termination. Log other errors at error level.
1029
1417
  const errorMessage = formatError(error);
1030
1418
  if (errorMessage.includes("aborted")) {
1031
- LOG.debug("recovery", "Monitor check aborted: %s.", errorMessage);
1419
+ LOG.debug("recovery:general", "Monitor check aborted: %s.", errorMessage);
1032
1420
  }
1033
1421
  else {
1034
1422
  LOG.error("Monitor check failed: %s.", errorMessage);