prismcast 1.5.2 → 1.6.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 (158) 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/channelSelection.js +14 -2
  6. package/dist/browser/channelSelection.js.map +1 -1
  7. package/dist/browser/index.d.ts +23 -9
  8. package/dist/browser/index.js +205 -88
  9. package/dist/browser/index.js.map +1 -1
  10. package/dist/browser/precaching.js +3 -3
  11. package/dist/browser/precaching.js.map +1 -1
  12. package/dist/browser/tuning/directv.js +13 -0
  13. package/dist/browser/tuning/directv.js.map +1 -1
  14. package/dist/browser/tuning/fox.js +12 -0
  15. package/dist/browser/tuning/fox.js.map +1 -1
  16. package/dist/browser/tuning/hbo.js +11 -0
  17. package/dist/browser/tuning/hbo.js.map +1 -1
  18. package/dist/browser/tuning/hulu.js +23 -0
  19. package/dist/browser/tuning/hulu.js.map +1 -1
  20. package/dist/browser/tuning/sling.js +34 -4
  21. package/dist/browser/tuning/sling.js.map +1 -1
  22. package/dist/browser/tuning/spectrum.js +12 -0
  23. package/dist/browser/tuning/spectrum.js.map +1 -1
  24. package/dist/browser/tuning/xfinity.d.ts +2 -0
  25. package/dist/browser/tuning/xfinity.js +607 -0
  26. package/dist/browser/tuning/xfinity.js.map +1 -0
  27. package/dist/browser/tuning/youtubeTv.js +14 -0
  28. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  29. package/dist/browser/video.d.ts +2 -0
  30. package/dist/browser/video.js +99 -17
  31. package/dist/browser/video.js.map +1 -1
  32. package/dist/channels/index.js +173 -56
  33. package/dist/channels/index.js.map +1 -1
  34. package/dist/config/health.d.ts +28 -28
  35. package/dist/config/health.js +52 -52
  36. package/dist/config/health.js.map +1 -1
  37. package/dist/config/paths.d.ts +12 -0
  38. package/dist/config/paths.js +16 -0
  39. package/dist/config/paths.js.map +1 -1
  40. package/dist/config/profiles.d.ts +3 -2
  41. package/dist/config/profiles.js +45 -34
  42. package/dist/config/profiles.js.map +1 -1
  43. package/dist/config/providers.d.ts +7 -0
  44. package/dist/config/providers.js +14 -0
  45. package/dist/config/providers.js.map +1 -1
  46. package/dist/config/sites.d.ts +28 -0
  47. package/dist/config/sites.js +93 -122
  48. package/dist/config/sites.js.map +1 -1
  49. package/dist/config/userChannels.d.ts +3 -0
  50. package/dist/config/userChannels.js +10 -3
  51. package/dist/config/userChannels.js.map +1 -1
  52. package/dist/config/userConfig.d.ts +1 -0
  53. package/dist/config/userConfig.js +4 -0
  54. package/dist/config/userConfig.js.map +1 -1
  55. package/dist/config/userProfiles.d.ts +0 -1
  56. package/dist/config/userProfiles.js +18 -10
  57. package/dist/config/userProfiles.js.map +1 -1
  58. package/dist/index.js +16 -7
  59. package/dist/index.js.map +1 -1
  60. package/dist/native/decrypt.d.ts +32 -0
  61. package/dist/native/decrypt.js +85 -0
  62. package/dist/native/decrypt.js.map +1 -0
  63. package/dist/native/index.d.ts +58 -0
  64. package/dist/native/index.js +341 -0
  65. package/dist/native/index.js.map +1 -0
  66. package/dist/native/intercept.d.ts +35 -0
  67. package/dist/native/intercept.js +184 -0
  68. package/dist/native/intercept.js.map +1 -0
  69. package/dist/native/probe.d.ts +48 -0
  70. package/dist/native/probe.js +270 -0
  71. package/dist/native/probe.js.map +1 -0
  72. package/dist/native/proxy.d.ts +52 -0
  73. package/dist/native/proxy.js +904 -0
  74. package/dist/native/proxy.js.map +1 -0
  75. package/dist/routes/config/channels/table.js +19 -25
  76. package/dist/routes/config/channels/table.js.map +1 -1
  77. package/dist/routes/config/providers.js +4 -4
  78. package/dist/routes/config/providers.js.map +1 -1
  79. package/dist/routes/debug.js +5 -5
  80. package/dist/routes/debug.js.map +1 -1
  81. package/dist/routes/hls.js +12 -2
  82. package/dist/routes/hls.js.map +1 -1
  83. package/dist/routes/index.d.ts +1 -0
  84. package/dist/routes/index.js +3 -0
  85. package/dist/routes/index.js.map +1 -1
  86. package/dist/routes/root/content.js +80 -20
  87. package/dist/routes/root/content.js.map +1 -1
  88. package/dist/routes/root/scripts/status.js +14 -13
  89. package/dist/routes/root/scripts/status.js.map +1 -1
  90. package/dist/routes/root/styles.js +4 -2
  91. package/dist/routes/root/styles.js.map +1 -1
  92. package/dist/streaming/fmp4Segmenter.d.ts +3 -0
  93. package/dist/streaming/fmp4Segmenter.js +143 -42
  94. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  95. package/dist/streaming/hls.d.ts +24 -19
  96. package/dist/streaming/hls.js +486 -261
  97. package/dist/streaming/hls.js.map +1 -1
  98. package/dist/streaming/hlsResume.d.ts +7 -0
  99. package/dist/streaming/hlsResume.js +22 -2
  100. package/dist/streaming/hlsResume.js.map +1 -1
  101. package/dist/streaming/hlsSegments.d.ts +42 -2
  102. package/dist/streaming/hlsSegments.js +109 -8
  103. package/dist/streaming/hlsSegments.js.map +1 -1
  104. package/dist/streaming/lifecycle.js +20 -2
  105. package/dist/streaming/lifecycle.js.map +1 -1
  106. package/dist/streaming/monitor.d.ts +1 -0
  107. package/dist/streaming/monitor.js +369 -21
  108. package/dist/streaming/monitor.js.map +1 -1
  109. package/dist/streaming/mp4Parser.d.ts +52 -6
  110. package/dist/streaming/mp4Parser.js +156 -30
  111. package/dist/streaming/mp4Parser.js.map +1 -1
  112. package/dist/streaming/mpegts.d.ts +2 -2
  113. package/dist/streaming/mpegts.js +156 -109
  114. package/dist/streaming/mpegts.js.map +1 -1
  115. package/dist/streaming/playlistBuilder.d.ts +38 -0
  116. package/dist/streaming/playlistBuilder.js +66 -0
  117. package/dist/streaming/playlistBuilder.js.map +1 -0
  118. package/dist/streaming/preroll.d.ts +106 -0
  119. package/dist/streaming/preroll.js +373 -0
  120. package/dist/streaming/preroll.js.map +1 -0
  121. package/dist/streaming/pretune.d.ts +8 -0
  122. package/dist/streaming/pretune.js +226 -0
  123. package/dist/streaming/pretune.js.map +1 -0
  124. package/dist/streaming/registry.d.ts +24 -3
  125. package/dist/streaming/registry.js +23 -1
  126. package/dist/streaming/registry.js.map +1 -1
  127. package/dist/streaming/setup.d.ts +7 -0
  128. package/dist/streaming/setup.js +27 -6
  129. package/dist/streaming/setup.js.map +1 -1
  130. package/dist/streaming/showInfo.d.ts +24 -0
  131. package/dist/streaming/showInfo.js +55 -3
  132. package/dist/streaming/showInfo.js.map +1 -1
  133. package/dist/streaming/statusEmitter.d.ts +6 -4
  134. package/dist/streaming/statusEmitter.js +9 -3
  135. package/dist/streaming/statusEmitter.js.map +1 -1
  136. package/dist/types/channels.d.ts +1 -0
  137. package/dist/types/index.d.ts +1 -1
  138. package/dist/types/profiles.d.ts +8 -1
  139. package/dist/types/selection.d.ts +4 -1
  140. package/dist/types/streaming.d.ts +7 -0
  141. package/dist/utils/chromeFetch.d.ts +20 -0
  142. package/dist/utils/chromeFetch.js +43 -0
  143. package/dist/utils/chromeFetch.js.map +1 -0
  144. package/dist/utils/debugFilter.js +15 -0
  145. package/dist/utils/debugFilter.js.map +1 -1
  146. package/dist/utils/delay.d.ts +16 -0
  147. package/dist/utils/delay.js +15 -1
  148. package/dist/utils/delay.js.map +1 -1
  149. package/dist/utils/ffmpeg.d.ts +1 -7
  150. package/dist/utils/ffmpeg.js +86 -127
  151. package/dist/utils/ffmpeg.js.map +1 -1
  152. package/dist/utils/index.d.ts +2 -0
  153. package/dist/utils/index.js +2 -0
  154. package/dist/utils/index.js.map +1 -1
  155. package/dist/utils/pid.d.ts +40 -0
  156. package/dist/utils/pid.js +83 -0
  157. package/dist/utils/pid.js.map +1 -0
  158. package/package.json +5 -5
@@ -2,10 +2,14 @@ import { EvaluateTimeoutError, LOG, formatError, getAbortSignal, isSessionClosed
2
2
  import { RECOVERY_METHODS, capitalize, 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
4
  import { getChannelLogo, getShowName } from "./showInfo.js";
5
- import { getLastSegmentSize, getStream, getStreamMemoryUsage } from "./registry.js";
5
+ import { getLastSegmentHasVideo, getLastSegmentSize, getStream, getStreamMemoryUsage } from "./registry.js";
6
6
  import { CONFIG } from "../config/index.js";
7
+ import { clearProbeCache } from "../native/probe.js";
7
8
  import { emitStreamHealthChanged } from "./statusEmitter.js";
8
9
  import { getClientSummary } from "./clients.js";
10
+ import { getEffectiveViewport } from "../config/presets.js";
11
+ import { getProviderBySlug } from "../browser/channelSelection.js";
12
+ import { refreshNativeManifest } from "../native/index.js";
9
13
  import { resizeAndMinimizeWindow } from "../browser/cdp.js";
10
14
  export function monitorPlaybackHealth(page, context, profile, url, streamId, streamInfo, onCircuitBreak, onTabReplacement) {
11
15
  /* Monitor state variables. These track the video's behavior over time and control recovery decisions.
@@ -90,11 +94,22 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
90
94
  // 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
95
  // 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
96
  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).
97
+ const TINY_SEGMENT_COUNT_TRIGGER = 10; // Default trigger count: 10 consecutive tiny segments (~20 seconds with 2-second segments).
98
+ // Resolve the provider-specific tiny segment count threshold once at monitor startup. Providers with extended static content (e.g., Xfinity commercial
99
+ // placeholders) set a higher value to tolerate longer periods of small segments without false positive tab replacements. Dead capture pipelines (segments with
100
+ // no video trafs) always use TINY_SEGMENT_COUNT_TRIGGER regardless of this setting.
101
+ const providerModule = streamInfo.providerTag ? getProviderBySlug(streamInfo.providerTag) : undefined;
102
+ const providerTinySegmentThreshold = providerModule?.tinySegmentThreshold ?? TINY_SEGMENT_COUNT_TRIGGER;
94
103
  // 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
104
  // 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
105
  // 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
106
  const SEGMENT_STALENESS_TIMEOUT = 20000; // 20 seconds.
107
+ // Resolution degradation detection. When the video element's intrinsic resolution is significantly below the configured viewport, the provider's ABR is delivering
108
+ // 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.
109
+ // 50% catches clear ABR degradation (768×432 on 1080p = 40%) while allowing legitimate 720p content on 1080p (67% > 50%).
110
+ const RESOLUTION_RATIO_THRESHOLD = 0.5;
111
+ // Grace period in milliseconds after stream start and after each recovery action. Gives ABR time to ramp up before flagging degradation.
112
+ const RESOLUTION_GRACE_PERIOD = 30000;
98
113
  // Fixed margin in milliseconds before the maxContinuousPlayback limit at which a proactive reload is triggered. Two minutes provides enough time for page
99
114
  // navigation and video reinitialization to complete before the site enforces its cutoff.
100
115
  const PROACTIVE_RELOAD_MARGIN_MS = 120000;
@@ -107,6 +122,232 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
107
122
  const selectorType = buildVideoSelectorType(profile);
108
123
  // Capture stream context for re-establishing on each interval tick. AsyncLocalStorage context is lost when entering setInterval callbacks.
109
124
  const streamContext = { channelName: streamInfo.channelName ?? undefined, streamId, url };
125
+ // Native stream health state. These variables are only used when the stream is in native mode. They track segment delivery health to detect stalled streams
126
+ // where the provider's manifest stops advancing or segments stop arriving. Native recovery uses the shared `recoveryInProgress` flag rather than a separate flag,
127
+ // since the interval callback already checks `recoveryInProgress` before dispatching to either the native or capture-mode health check path.
128
+ let nativeLastCheckedSegmentIndex = 0;
129
+ let nativeLastSegmentAdvanceTime = Date.now();
130
+ let nativeHealthIssueType = null;
131
+ let nativeHealthIssueTime = null;
132
+ let nativeRecoveryAttempts = 0;
133
+ // Resolution degradation monitoring. Separate from the existing recovery escalation (L1-L4) which handles broken playback. Resolution degradation is a quality
134
+ // issue — the stream works but at lower-than-expected resolution. Uses its own tracking and two-step escalation: page reload, then tab replacement.
135
+ let resolutionGraceEnd = Date.now() + RESOLUTION_GRACE_PERIOD;
136
+ let resolutionRecoveryAttempt = 0;
137
+ /**
138
+ * Checks segment delivery health for native streams. Detects stalled streams by comparing the proxy's segment index and last segment timestamp against thresholds.
139
+ * Recovery follows three escalation levels per the plan:
140
+ *
141
+ * - L1: Re-fetch manifest (handled by the proxy's internal retry loop — consecutive failures up to the threshold)
142
+ * - L2: Reload page for fresh tokens (same mechanism as proactive token refresh, but triggered by segment staleness)
143
+ * - L3: Fall back to capture mode via tab replacement (stops native proxy, creates fresh page with capture pipeline)
144
+ *
145
+ * Note: The `recoveryInProgress` guard at the top of the interval callback prevents re-entry during async L2/L3 recovery. The native path does not need its own
146
+ * guard — it reuses the shared flag.
147
+ *
148
+ * @param entry - The stream registry entry for the native stream.
149
+ */
150
+ function checkNativeStreamHealth(entry) {
151
+ const proxy = entry.nativeProxy;
152
+ if (!proxy) {
153
+ emitStatusUpdate();
154
+ return;
155
+ }
156
+ const now = Date.now();
157
+ const currentSegmentIndex = proxy.getSegmentIndex();
158
+ const lastSegmentTime = proxy.getLastSegmentTime();
159
+ const targetDuration = proxy.getTargetDuration();
160
+ const consecutiveErrors = proxy.getConsecutiveErrors();
161
+ const storeKey = entry.info.storeKey;
162
+ // 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
163
+ // errors (HTTP 403, network failures) have already been detected by the proxy's internal retry loop.
164
+ if (proxy.hasErrored()) {
165
+ LOG.debug("native:monitor", "Native proxy errored for %s. Initiating capture fallback.", storeKey);
166
+ nativeHealthIssueType = "proxy error";
167
+ nativeHealthIssueTime = now;
168
+ void runWithStreamContext(streamContext, async () => {
169
+ await executeNativeL3Fallback(entry);
170
+ });
171
+ return;
172
+ }
173
+ // Check if new segments have been produced since the last tick.
174
+ if (currentSegmentIndex > nativeLastCheckedSegmentIndex) {
175
+ nativeLastCheckedSegmentIndex = currentSegmentIndex;
176
+ nativeLastSegmentAdvanceTime = now;
177
+ // Clear any previous issue tracking when segments are flowing.
178
+ if (nativeHealthIssueType) {
179
+ nativeHealthIssueType = null;
180
+ nativeHealthIssueTime = null;
181
+ nativeRecoveryAttempts = 0;
182
+ LOG.debug("native:monitor", "Native stream healthy for %s. Segments advancing (index %s).", storeKey, currentSegmentIndex);
183
+ }
184
+ }
185
+ // Calculate staleness: time since the last new segment was produced.
186
+ const stalenessMs = now - nativeLastSegmentAdvanceTime;
187
+ const stalenessThreshold = targetDuration * 2 * 1000;
188
+ // Classify health based on segment delivery metrics.
189
+ let nativeHealth = "healthy";
190
+ if (consecutiveErrors > 0) {
191
+ nativeHealth = "recovering";
192
+ if (!nativeHealthIssueType) {
193
+ nativeHealthIssueType = "fetch errors";
194
+ nativeHealthIssueTime = now;
195
+ }
196
+ LOG.debug("native:monitor", "Native stream recovering for %s. Consecutive errors: %s.", storeKey, consecutiveErrors);
197
+ }
198
+ else if ((stalenessMs > stalenessThreshold) && (lastSegmentTime > 0)) {
199
+ // Only flag staleness after at least one segment has been produced (lastSegmentTime > 0) and the staleness exceeds the threshold.
200
+ nativeHealth = "stalled";
201
+ if (!nativeHealthIssueType) {
202
+ nativeHealthIssueType = "segment stall";
203
+ nativeHealthIssueTime = now;
204
+ }
205
+ const staleSec = Math.round(stalenessMs / 1000);
206
+ LOG.debug("native:monitor", "Native stream stalled for %s. No new segments in %ss (threshold: %ss).", storeKey, staleSec, Math.round(stalenessThreshold / 1000));
207
+ // 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
208
+ // are rejected. The proxy continues serving cached segments during the reload.
209
+ if ((stalenessMs > (targetDuration * 4 * 1000)) && (nativeRecoveryAttempts === 0)) {
210
+ LOG.warn("Native stream stalled for %s. No new segments in %ss. Attempting recovery.", storeKey, staleSec);
211
+ nativeRecoveryAttempts++;
212
+ void runWithStreamContext(streamContext, async () => {
213
+ await executeNativeL2Recovery(entry);
214
+ });
215
+ return;
216
+ }
217
+ // L3: At 6× target duration (or if L2 was already attempted), fall back to capture mode via tab replacement.
218
+ if ((stalenessMs > (targetDuration * 6 * 1000)) || ((stalenessMs > (targetDuration * 4 * 1000)) && (nativeRecoveryAttempts > 0))) {
219
+ LOG.warn("Falling back to capture mode for %s: native streaming stalled after recovery attempt.", storeKey);
220
+ void runWithStreamContext(streamContext, async () => {
221
+ await executeNativeL3Fallback(entry);
222
+ });
223
+ return;
224
+ }
225
+ }
226
+ emitNativeStatus(entry, nativeHealth);
227
+ }
228
+ /**
229
+ * Emits a status update with native-specific health classification. Populates meaningful fields (health, issue tracking, memory, clients) and zeroes video-specific
230
+ * fields that are not applicable to native streams.
231
+ *
232
+ * @param entry - The stream registry entry.
233
+ * @param health - The health status to report.
234
+ */
235
+ function emitNativeStatus(entry, health) {
236
+ if (intervalCleared) {
237
+ return;
238
+ }
239
+ const now = Date.now();
240
+ const memoryBytes = getStreamMemoryUsage(entry).total;
241
+ const channelKey = entry.info.storeKey;
242
+ const clientSummary = getClientSummary(streamInfo.numericStreamId);
243
+ const escalation = (health === "stalled") ? 1 : ((health === "recovering") ? 2 : 0);
244
+ const status = {
245
+ bufferingDuration: null,
246
+ channel: streamInfo.channelName,
247
+ clientCount: clientSummary.total,
248
+ clients: clientSummary.clients,
249
+ currentTime: 0,
250
+ duration: Math.round((now - streamInfo.startTime.getTime()) / 1000),
251
+ escalationLevel: escalation,
252
+ health,
253
+ id: streamInfo.numericStreamId,
254
+ lastIssueTime: nativeHealthIssueTime,
255
+ lastIssueType: nativeHealthIssueType,
256
+ lastRecoveryTime: null,
257
+ logoUrl: channelKey ? (getChannelLogo(channelKey) ?? "") : "",
258
+ memoryBytes,
259
+ networkState: 0,
260
+ pageReloadsInWindow: 0,
261
+ providerName: streamInfo.providerName,
262
+ readyState: 0,
263
+ recoveryAttempts: nativeRecoveryAttempts,
264
+ showName: getShowName(streamInfo.numericStreamId),
265
+ startTime: streamInfo.startTime.toISOString(),
266
+ streamingMode: entry.streamingMode,
267
+ url
268
+ };
269
+ emitStreamHealthChanged(status);
270
+ }
271
+ /**
272
+ * L2 recovery for native streams: reloads the page to get fresh authentication tokens and re-intercepts the manifest. Delegates to the shared refreshNativeManifest
273
+ * helper in the coordinator module, which handles interceptor installation, navigation, probing, isStopped() guards, and proxy updates.
274
+ *
275
+ * @param entry - The stream registry entry.
276
+ */
277
+ async function executeNativeL2Recovery(entry) {
278
+ const proxy = entry.nativeProxy;
279
+ if (!proxy || proxy.isStopped()) {
280
+ return;
281
+ }
282
+ recoveryInProgress = true;
283
+ LOG.debug("native:monitor", "Starting L2 recovery (page reload) for %s.", entry.info.storeKey);
284
+ try {
285
+ const success = await refreshNativeManifest({
286
+ channelName: entry.info.storeKey,
287
+ page: currentPage,
288
+ proxy,
289
+ streamIdStr: streamId,
290
+ url
291
+ });
292
+ if (success) {
293
+ // Reset staleness tracking so the monitor gives the refreshed stream time to produce segments.
294
+ nativeLastSegmentAdvanceTime = Date.now();
295
+ }
296
+ }
297
+ finally {
298
+ recoveryInProgress = false;
299
+ }
300
+ }
301
+ /**
302
+ * 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
303
+ * the stream to capture mode. The existing tab replacement infrastructure handles page creation, capture initialization, segmenter creation, and registry updates.
304
+ *
305
+ * 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
306
+ * execute. By the time L3 runs, the stream is already terminated and executeTabReplacement returns null, which we handle as a failed outcome.
307
+ *
308
+ * @param entry - The stream registry entry.
309
+ */
310
+ async function executeNativeL3Fallback(entry) {
311
+ if (!onTabReplacement) {
312
+ LOG.warn("Capture fallback not available for %s: no tab replacement handler.", entry.info.storeKey);
313
+ onCircuitBreak();
314
+ return;
315
+ }
316
+ LOG.debug("native:monitor", "Starting L3 fallback (capture mode) for %s.", entry.info.storeKey);
317
+ // 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.
318
+ if (entry.nativeProxy) {
319
+ entry.nativeProxy.stop();
320
+ entry.nativeProxy = null;
321
+ }
322
+ // Use the existing tab replacement infrastructure. It sets recoveryInProgress = true internally and clears it in finalizeTabReplacement. It creates a new page
323
+ // with capture, navigates, sets up playback, creates a segmenter, and updates the registry entry (page, rawCaptureStream, ffmpegProcess, segmenter).
324
+ const outcome = await executeTabReplacement("native fallback to capture");
325
+ if (outcome.outcome === "success") {
326
+ // Tab replacement succeeded. Update the registry to reflect capture mode. The tab replacement handler already set page, rawCaptureStream, ffmpegProcess,
327
+ // and segmenter on the registry entry. We just need to update the streaming mode and clear audio state.
328
+ entry.streamingMode = "capture";
329
+ // Clear separate audio state from the native proxy. Without this, hasAudio remains true and the HLS handler continues serving the master playlist (referencing
330
+ // video.m3u8 and audio.m3u8) instead of the capture segmenter's variant playlist. Clients that cached the master playlist structure would request stale audio
331
+ // and video variant playlists pointing to segments that are no longer being updated.
332
+ entry.hls.hasAudio = false;
333
+ entry.hls.audioPlaylist = "";
334
+ entry.hls.audioSegments.clear();
335
+ entry.hls.videoPlaylist = "";
336
+ // Clear the probe cache so subsequent tunes to this channel don't re-attempt native streaming.
337
+ clearProbeCache(entry.info.storeKey);
338
+ LOG.info("Switched to capture mode for %s: native streaming failed.", entry.info.storeKey);
339
+ // The monitor's next tick will see streamingMode === "capture" and run the normal video element monitoring path. The state reset from
340
+ // applyTabReplacementSuccess (called by executeTabReplacement) already initialized all capture-mode monitor variables.
341
+ }
342
+ else if (outcome.outcome === "terminated") {
343
+ // Circuit breaker tripped during tab replacement. Stream is being terminated.
344
+ LOG.warn("Capture fallback failed for %s: circuit breaker tripped.", entry.info.storeKey);
345
+ }
346
+ else {
347
+ // Tab replacement failed but stream wasn't terminated. The circuit breaker will handle it on the next failure.
348
+ LOG.warn("Capture fallback failed for %s: tab replacement unsuccessful.", entry.info.storeKey);
349
+ }
350
+ }
110
351
  // 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
352
  // 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
353
  const markStreamDiscontinuity = () => {
@@ -143,6 +384,12 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
143
384
  * Emits a status update for this stream.
144
385
  */
145
386
  function emitStatusUpdate() {
387
+ // 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
388
+ // emitStreamRemoved(). Without this guard, the emitStreamHealthChanged() call below would re-add the dead stream to the streamStatuses Map, creating a zombie
389
+ // entry that persists in SSE snapshots indefinitely.
390
+ if (intervalCleared) {
391
+ return;
392
+ }
146
393
  const now = Date.now();
147
394
  // Get current memory usage from the stream's HLS segment buffers.
148
395
  const entry = getStream(streamInfo.numericStreamId);
@@ -173,6 +420,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
173
420
  recoveryAttempts: totalRecoveryAttempts,
174
421
  showName: getShowName(streamInfo.numericStreamId),
175
422
  startTime: streamInfo.startTime.toISOString(),
423
+ streamingMode: entry?.streamingMode ?? "capture",
176
424
  url
177
425
  };
178
426
  emitStreamHealthChanged(status);
@@ -209,6 +457,27 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
209
457
  videoNotFoundCount = 0;
210
458
  stallCount = 0;
211
459
  }
460
+ /**
461
+ * Resets resolution monitoring state. Called when resolution reaches expected levels or after any recovery action that restarts ABR negotiation.
462
+ */
463
+ function resetResolutionState() {
464
+ resolutionGraceEnd = Date.now() + RESOLUTION_GRACE_PERIOD;
465
+ resolutionRecoveryAttempt = 0;
466
+ }
467
+ /**
468
+ * 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
469
+ * responsible for logging and handling the rate-limited case — consequences differ by context (circuit break, deferral, fallback to L2).
470
+ * @returns True if a page reload is allowed and the timestamp has been recorded, false if the rate limit has been reached.
471
+ */
472
+ function isPageReloadAllowed() {
473
+ const reloadWindow = Date.now() - CONFIG.playback.pageReloadWindow;
474
+ pageReloadTimestamps = pageReloadTimestamps.filter((ts) => ts > reloadWindow);
475
+ if (pageReloadTimestamps.length >= CONFIG.playback.maxPageReloads) {
476
+ return false;
477
+ }
478
+ pageReloadTimestamps.push(Date.now());
479
+ return true;
480
+ }
212
481
  /**
213
482
  * Resets escalation level and related flags. Called after successful recovery to allow the stream to start from level 0 on future issues.
214
483
  */
@@ -256,6 +525,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
256
525
  resetRecoveryCounters();
257
526
  resetEscalationState();
258
527
  resetSegmentMonitoringState();
528
+ resetResolutionState();
259
529
  setRecoveryGracePeriod(3);
260
530
  resetCircuitBreaker(circuitBreaker);
261
531
  }
@@ -423,6 +693,13 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
423
693
  emitStatusUpdate();
424
694
  return;
425
695
  }
696
+ // 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
697
+ // startup because the streaming mode is set after the monitor starts (native streaming is attempted after setupStream returns).
698
+ const nativeEntry = getStream(streamInfo.numericStreamId);
699
+ if (nativeEntry?.streamingMode === "native") {
700
+ checkNativeStreamHealth(nativeEntry);
701
+ return;
702
+ }
426
703
  // Re-establish stream context for this interval tick. AsyncLocalStorage context is lost when entering setInterval callbacks.
427
704
  runWithStreamContext(streamContext, async () => {
428
705
  try {
@@ -556,15 +833,12 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
556
833
  recoveryInProgress = true;
557
834
  recordRecoveryAttempt(metrics, RECOVERY_METHODS.pageNavigation);
558
835
  // 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) {
836
+ if (!isPageReloadAllowed()) {
562
837
  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
838
  clearInterval(interval);
564
839
  onCircuitBreak();
565
840
  return;
566
841
  }
567
- pageReloadTimestamps.push(now);
568
842
  // Use the unified recovery function with validation.
569
843
  const recoveryResult = await performPageNavigationRecovery();
570
844
  // Page navigation disrupted the video stream. Mark a discontinuity regardless of navigation success so HLS clients resynchronize their decoders.
@@ -583,6 +857,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
583
857
  resetRecoveryCounters();
584
858
  resetEscalationState();
585
859
  resetSegmentMonitoringState();
860
+ resetResolutionState();
586
861
  }
587
862
  else {
588
863
  consecutiveNavigationFailures++;
@@ -661,8 +936,14 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
661
936
  if (segmentSize < TINY_SEGMENT_THRESHOLD) {
662
937
  consecutiveTinySegments++;
663
938
  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);
939
+ // Check track composition to determine the effective threshold. Dead capture pipelines produce audio-only segments (hasVideo=false) and always use the
940
+ // default count for fast detection. Segments with video trafs present (hasVideo=true or null) use the provider-specific threshold, which may be higher
941
+ // for providers with extended static content (e.g., Xfinity commercial placeholders lasting several minutes).
942
+ const hasVideo = getLastSegmentHasVideo(sizeCheckEntry);
943
+ const effectiveThreshold = (hasVideo === false) ? TINY_SEGMENT_COUNT_TRIGGER : providerTinySegmentThreshold;
944
+ LOG.debug("recovery:tracks", "Below-threshold segment: %d bytes, hasVideo=%s, consecutive=%d, threshold=%d.", segmentSize, String(hasVideo), consecutiveTinySegments, effectiveThreshold);
945
+ if (consecutiveTinySegments >= effectiveThreshold) {
946
+ LOG.warn("Detected %d consecutive tiny segments (%d bytes, hasVideo=%s) — capture pipeline is not responding.", consecutiveTinySegments, segmentSize, String(hasVideo));
666
947
  // Trigger tab replacement if available, otherwise let circuit breaker handle it via segmentProductionStalled. Return unconditionally after tab
667
948
  // replacement (matching stalled-capture and unresponsive-tab triggers) to avoid falling through the rest of the tick with stale pre-replacement state.
668
949
  if (onTabReplacement && !recoveryInProgress) {
@@ -679,7 +960,8 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
679
960
  // Valid segment size. Check for spontaneous recovery from tiny segment state. We don't mark discontinuity here - only tab replacement marks discontinuity.
680
961
  // Self-healing may be transient and not require decoder reset.
681
962
  if (wasInTinySegmentState) {
682
- LOG.debug("recovery:segments", "Segment production self-healed (%d bytes).", segmentSize);
963
+ const hasVideo = getLastSegmentHasVideo(sizeCheckEntry);
964
+ LOG.debug("recovery:segments", "Segment production self-healed (%d bytes, hasVideo=%s).", segmentSize, String(hasVideo));
683
965
  }
684
966
  // Reset tiny segment tracking.
685
967
  consecutiveTinySegments = 0;
@@ -743,6 +1025,78 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
743
1025
  fullscreenReapplyCount = 0;
744
1026
  }
745
1027
  }
1028
+ /* Resolution degradation detection. When a provider's ABR delivers content at a resolution significantly below the configured viewport, the captured frame
1029
+ * shows a small video in the top-left corner of a larger viewport. This is distinct from fullscreen issues (which the fullscreen reinforcement above handles) —
1030
+ * the video CSS fills the viewport, but the intrinsic media resolution is low. We monitor video.videoWidth/videoHeight and compare against the effective viewport
1031
+ * dimensions. The check is gated by healthy playback, outside recovery grace, and non-zero intrinsic dimensions. The 30-second grace period after stream start
1032
+ * and each recovery allows ABR time to ramp up quality. After two unsuccessful recovery attempts (page reload + tab replacement), the system accepts the
1033
+ * resolution to avoid infinite loops on legitimately low-resolution content.
1034
+ */
1035
+ if (isProgressing && !state.paused && !state.error && !state.ended && !withinRecoveryGrace && (state.videoWidth > 0) && (state.videoHeight > 0)) {
1036
+ const viewport = getEffectiveViewport(CONFIG);
1037
+ const widthRatio = state.videoWidth / viewport.width;
1038
+ const heightRatio = state.videoHeight / viewport.height;
1039
+ const isDegraded = (widthRatio < RESOLUTION_RATIO_THRESHOLD) || (heightRatio < RESOLUTION_RATIO_THRESHOLD);
1040
+ if (isDegraded) {
1041
+ LOG.debug("recovery:resolution", "Video resolution: %s\u00d7%s (viewport: %s\u00d7%s, ratio: %s%%\u00d7%s%%).", String(state.videoWidth), String(state.videoHeight), String(viewport.width), String(viewport.height), String(Math.round(widthRatio * 100)), String(Math.round(heightRatio * 100)));
1042
+ }
1043
+ // Escalation step 1: page reload. Forces the provider's ABR to restart quality negotiation.
1044
+ if (isDegraded && (now >= resolutionGraceEnd) && (resolutionRecoveryAttempt === 0)) {
1045
+ LOG.warn("Video resolution degraded (%s\u00d7%s in %s\u00d7%s viewport). Recovering via %s.", String(state.videoWidth), String(state.videoHeight), String(viewport.width), String(viewport.height), RECOVERY_METHODS.pageNavigation);
1046
+ recoveryInProgress = true;
1047
+ // Check page reload rate limit before attempting.
1048
+ if (!isPageReloadAllowed()) {
1049
+ LOG.warn("Resolution recovery deferred — page navigation rate limit reached (%s in %s minutes).", CONFIG.playback.maxPageReloads, Math.round(CONFIG.playback.pageReloadWindow / 60000));
1050
+ // Defer by pushing grace end forward to avoid re-triggering every 2 seconds.
1051
+ resolutionGraceEnd = now + RESOLUTION_GRACE_PERIOD;
1052
+ recoveryInProgress = false;
1053
+ emitStatusUpdate();
1054
+ return;
1055
+ }
1056
+ pendingReMinimize = true;
1057
+ const recoveryResult = await performPageNavigationRecovery();
1058
+ markStreamDiscontinuity();
1059
+ resolutionRecoveryAttempt = 1;
1060
+ resolutionGraceEnd = now + RESOLUTION_GRACE_PERIOD;
1061
+ if (recoveryResult.success && recoveryResult.newContext) {
1062
+ currentContext = recoveryResult.newContext;
1063
+ lastPageNavigationTime = Date.now();
1064
+ LOG.info("Resolution recovery: page reload completed. Waiting for ABR ramp-up.");
1065
+ }
1066
+ else {
1067
+ LOG.warn("Resolution recovery: page reload unsuccessful.");
1068
+ }
1069
+ recoveryInProgress = false;
1070
+ emitStatusUpdate();
1071
+ return;
1072
+ }
1073
+ // Escalation step 2: tab replacement. Creates a fresh page with new capture pipeline and network connections.
1074
+ if (isDegraded && (now >= resolutionGraceEnd) && (resolutionRecoveryAttempt === 1)) {
1075
+ if (onTabReplacement) {
1076
+ LOG.warn("Video resolution still degraded (%s\u00d7%s) after page reload. Recovering via %s.", String(state.videoWidth), String(state.videoHeight), RECOVERY_METHODS.tabReplacement);
1077
+ const outcome = await executeTabReplacement("resolution degraded");
1078
+ resolutionRecoveryAttempt = 2;
1079
+ resolutionGraceEnd = now + RESOLUTION_GRACE_PERIOD;
1080
+ if (outcome.outcome === "terminated") {
1081
+ return;
1082
+ }
1083
+ return;
1084
+ }
1085
+ // Tab replacement not available — skip directly to acceptance.
1086
+ resolutionRecoveryAttempt = 2;
1087
+ }
1088
+ // Acceptance: resolution still degraded after both recovery attempts. Log once and stop retrying.
1089
+ if (isDegraded && (now >= resolutionGraceEnd) && (resolutionRecoveryAttempt === 2)) {
1090
+ LOG.warn("Video resolution remains degraded (%s\u00d7%s in %s\u00d7%s viewport) after recovery attempts. Accepting current resolution.", String(state.videoWidth), String(state.videoHeight), String(viewport.width), String(viewport.height));
1091
+ resolutionRecoveryAttempt = 3;
1092
+ }
1093
+ // Resolution is good: clear tracking state so future degradation starts fresh.
1094
+ if (!isDegraded && (resolutionRecoveryAttempt > 0)) {
1095
+ LOG.info("Video resolution restored (%s\u00d7%s).", String(state.videoWidth), String(state.videoHeight));
1096
+ resolutionRecoveryAttempt = 0;
1097
+ resolutionGraceEnd = 0;
1098
+ }
1099
+ }
746
1100
  /* 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
1101
  * This hysteresis prevents reacting to single-frame hiccups.
748
1102
  */
@@ -811,9 +1165,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
811
1165
  recoveryInProgress = true;
812
1166
  // Check page reload rate limit before attempting. Proactive reload is best-effort maintenance — if the reload budget is exhausted from recent error
813
1167
  // 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) {
1168
+ if (!isPageReloadAllowed()) {
817
1169
  LOG.warn("Proactive reload deferred — page navigation rate limit reached (%s in %s minutes).", CONFIG.playback.maxPageReloads, Math.round(CONFIG.playback.pageReloadWindow / 60000));
818
1170
  // 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
1171
  // period spaces out re-checks, and the rate-limit window (default 15 minutes) will eventually expire old timestamps to allow the proactive reload. We
@@ -824,7 +1176,6 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
824
1176
  emitStatusUpdate();
825
1177
  return;
826
1178
  }
827
- pageReloadTimestamps.push(now);
828
1179
  const recoveryResult = await performPageNavigationRecovery();
829
1180
  // Page navigation disrupted the video stream. Mark a discontinuity so HLS clients resynchronize their decoders.
830
1181
  markStreamDiscontinuity();
@@ -835,6 +1186,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
835
1186
  LOG.info("Proactive reload completed successfully.");
836
1187
  resetRecoveryCounters();
837
1188
  resetSegmentMonitoringState();
1189
+ resetResolutionState();
838
1190
  }
839
1191
  else {
840
1192
  LOG.warn("Proactive reload unsuccessful. Will retry after recovery grace period.");
@@ -936,6 +1288,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
936
1288
  }
937
1289
  // Set grace period to give this recovery level time to take effect before the next check.
938
1290
  recoveryGraceUntil = now + recoveryGracePeriods[escalationLevel];
1291
+ resetResolutionState();
939
1292
  }
940
1293
  else {
941
1294
  /* Level 3: Page navigation recovery. This is the most aggressive recovery - we navigate to the URL again and reinitialize everything.
@@ -950,20 +1303,14 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
950
1303
  sourceReloadAttempted = false;
951
1304
  }
952
1305
  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) {
1306
+ // Check page reload limit to prevent excessive navigations.
1307
+ if (!isPageReloadAllowed()) {
960
1308
  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
1309
  escalationLevel = 2;
962
1310
  // Reset source reload tracking so the fallback L2 gets a fair chance.
963
1311
  sourceReloadAttempted = false;
964
1312
  }
965
1313
  else {
966
- pageReloadTimestamps.push(now);
967
1314
  // Use the unified recovery function with validation.
968
1315
  const recoveryResult = await performPageNavigationRecovery();
969
1316
  // Page navigation disrupted the video stream. Mark a discontinuity regardless of navigation success so HLS clients resynchronize their decoders.
@@ -982,6 +1329,7 @@ export function monitorPlaybackHealth(page, context, profile, url, streamId, str
982
1329
  resetRecoveryCounters();
983
1330
  resetEscalationState();
984
1331
  resetSegmentMonitoringState();
1332
+ resetResolutionState();
985
1333
  }
986
1334
  else {
987
1335
  consecutiveNavigationFailures++;