prismcast 1.6.0 → 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 (194) hide show
  1. package/dist/browser/cdp.js +1 -1
  2. package/dist/browser/cdp.js.map +1 -1
  3. package/dist/browser/channelSelection.d.ts +10 -34
  4. package/dist/browser/channelSelection.js +19 -96
  5. package/dist/browser/channelSelection.js.map +1 -1
  6. package/dist/browser/display.d.ts +19 -0
  7. package/dist/browser/display.js +16 -11
  8. package/dist/browser/display.js.map +1 -1
  9. package/dist/browser/index.d.ts +9 -46
  10. package/dist/browser/index.js +136 -191
  11. package/dist/browser/index.js.map +1 -1
  12. package/dist/browser/login.d.ts +65 -0
  13. package/dist/browser/login.js +193 -0
  14. package/dist/browser/login.js.map +1 -0
  15. package/dist/browser/precaching.js +12 -0
  16. package/dist/browser/precaching.js.map +1 -1
  17. package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
  18. package/dist/browser/tuning/comcastPolymer.js +655 -0
  19. package/dist/browser/tuning/comcastPolymer.js.map +1 -0
  20. package/dist/browser/tuning/cox.d.ts +2 -0
  21. package/dist/browser/tuning/cox.js +14 -0
  22. package/dist/browser/tuning/cox.js.map +1 -0
  23. package/dist/browser/tuning/directv.js +1 -1
  24. package/dist/browser/tuning/directv.js.map +1 -1
  25. package/dist/browser/tuning/fox.js +2 -2
  26. package/dist/browser/tuning/fox.js.map +1 -1
  27. package/dist/browser/tuning/hbo.js +1 -1
  28. package/dist/browser/tuning/hbo.js.map +1 -1
  29. package/dist/browser/tuning/hulu.js +1 -1
  30. package/dist/browser/tuning/hulu.js.map +1 -1
  31. package/dist/browser/tuning/shared.d.ts +46 -0
  32. package/dist/browser/tuning/shared.js +100 -0
  33. package/dist/browser/tuning/shared.js.map +1 -0
  34. package/dist/browser/tuning/sling.js +1 -1
  35. package/dist/browser/tuning/sling.js.map +1 -1
  36. package/dist/browser/tuning/spectrum.js +1 -1
  37. package/dist/browser/tuning/spectrum.js.map +1 -1
  38. package/dist/browser/tuning/thumbnailRow.js +1 -1
  39. package/dist/browser/tuning/thumbnailRow.js.map +1 -1
  40. package/dist/browser/tuning/tileClick.js +1 -1
  41. package/dist/browser/tuning/tileClick.js.map +1 -1
  42. package/dist/browser/tuning/xfinity.js +10 -603
  43. package/dist/browser/tuning/xfinity.js.map +1 -1
  44. package/dist/browser/tuning/youtubeTv.js +1 -1
  45. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  46. package/dist/browser/video.d.ts +18 -0
  47. package/dist/browser/video.js +69 -107
  48. package/dist/browser/video.js.map +1 -1
  49. package/dist/channels/index.d.ts +2 -2
  50. package/dist/channels/index.js +2075 -800
  51. package/dist/channels/index.js.map +1 -1
  52. package/dist/config/health.js +20 -2
  53. package/dist/config/health.js.map +1 -1
  54. package/dist/config/index.js +29 -64
  55. package/dist/config/index.js.map +1 -1
  56. package/dist/config/providerPacks.js +4 -1
  57. package/dist/config/providerPacks.js.map +1 -1
  58. package/dist/config/providers.d.ts +12 -5
  59. package/dist/config/providers.js +110 -94
  60. package/dist/config/providers.js.map +1 -1
  61. package/dist/config/sites.js +15 -9
  62. package/dist/config/sites.js.map +1 -1
  63. package/dist/config/userChannels.d.ts +50 -2
  64. package/dist/config/userChannels.js +212 -10
  65. package/dist/config/userChannels.js.map +1 -1
  66. package/dist/config/userConfig.d.ts +1 -1
  67. package/dist/config/userConfig.js +2 -15
  68. package/dist/config/userConfig.js.map +1 -1
  69. package/dist/hdhr/channelMap.d.ts +1 -1
  70. package/dist/hdhr/channelMap.js +4 -4
  71. package/dist/hdhr/channelMap.js.map +1 -1
  72. package/dist/index.js +9 -15
  73. package/dist/index.js.map +1 -1
  74. package/dist/native/index.d.ts +3 -0
  75. package/dist/native/index.js +2 -1
  76. package/dist/native/index.js.map +1 -1
  77. package/dist/native/probe.d.ts +4 -3
  78. package/dist/native/probe.js +36 -12
  79. package/dist/native/probe.js.map +1 -1
  80. package/dist/native/proxy.d.ts +2 -0
  81. package/dist/native/proxy.js +369 -326
  82. package/dist/native/proxy.js.map +1 -1
  83. package/dist/routes/components.d.ts +43 -0
  84. package/dist/routes/components.js +122 -0
  85. package/dist/routes/components.js.map +1 -1
  86. package/dist/routes/config/channels/index.d.ts +2 -2
  87. package/dist/routes/config/channels/index.js +1 -1
  88. package/dist/routes/config/channels/index.js.map +1 -1
  89. package/dist/routes/config/channels/routes.js +443 -65
  90. package/dist/routes/config/channels/routes.js.map +1 -1
  91. package/dist/routes/config/channels/table.d.ts +76 -0
  92. package/dist/routes/config/channels/table.js +352 -66
  93. package/dist/routes/config/channels/table.js.map +1 -1
  94. package/dist/routes/config/index.d.ts +1 -1
  95. package/dist/routes/config/index.js +1 -1
  96. package/dist/routes/config/index.js.map +1 -1
  97. package/dist/routes/config/providers.d.ts +6 -6
  98. package/dist/routes/config/providers.js +69 -95
  99. package/dist/routes/config/providers.js.map +1 -1
  100. package/dist/routes/index.d.ts +0 -16
  101. package/dist/routes/index.js +0 -17
  102. package/dist/routes/index.js.map +1 -1
  103. package/dist/routes/providers.js +106 -3
  104. package/dist/routes/providers.js.map +1 -1
  105. package/dist/routes/root/content.js +55 -38
  106. package/dist/routes/root/content.js.map +1 -1
  107. package/dist/routes/root/index.js +2 -1
  108. package/dist/routes/root/index.js.map +1 -1
  109. package/dist/routes/root/scripts/channels.js +772 -312
  110. package/dist/routes/root/scripts/channels.js.map +1 -1
  111. package/dist/routes/root/scripts/config.js +596 -544
  112. package/dist/routes/root/scripts/config.js.map +1 -1
  113. package/dist/routes/root/scripts/index.d.ts +1 -0
  114. package/dist/routes/root/scripts/index.js +1 -0
  115. package/dist/routes/root/scripts/index.js.map +1 -1
  116. package/dist/routes/root/scripts/shared.d.ts +6 -0
  117. package/dist/routes/root/scripts/shared.js +391 -0
  118. package/dist/routes/root/scripts/shared.js.map +1 -0
  119. package/dist/routes/root/scripts/status.js +36 -21
  120. package/dist/routes/root/scripts/status.js.map +1 -1
  121. package/dist/routes/root/styles.js +113 -8
  122. package/dist/routes/root/styles.js.map +1 -1
  123. package/dist/service/commands.js +2 -1
  124. package/dist/service/commands.js.map +1 -1
  125. package/dist/streaming/fmp4Segmenter.d.ts +2 -0
  126. package/dist/streaming/fmp4Segmenter.js +7 -4
  127. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  128. package/dist/streaming/hls.js +269 -188
  129. package/dist/streaming/hls.js.map +1 -1
  130. package/dist/streaming/hlsResume.d.ts +9 -3
  131. package/dist/streaming/hlsResume.js +13 -6
  132. package/dist/streaming/hlsResume.js.map +1 -1
  133. package/dist/streaming/hlsSegments.d.ts +1 -2
  134. package/dist/streaming/hlsSegments.js +48 -46
  135. package/dist/streaming/hlsSegments.js.map +1 -1
  136. package/dist/streaming/lifecycle.js +8 -12
  137. package/dist/streaming/lifecycle.js.map +1 -1
  138. package/dist/streaming/monitor.js +625 -585
  139. package/dist/streaming/monitor.js.map +1 -1
  140. package/dist/streaming/mpegts.js +1 -1
  141. package/dist/streaming/mpegts.js.map +1 -1
  142. package/dist/streaming/preroll.d.ts +40 -20
  143. package/dist/streaming/preroll.js +147 -86
  144. package/dist/streaming/preroll.js.map +1 -1
  145. package/dist/streaming/recovery.d.ts +0 -6
  146. package/dist/streaming/recovery.js +0 -8
  147. package/dist/streaming/recovery.js.map +1 -1
  148. package/dist/streaming/registry.d.ts +36 -9
  149. package/dist/streaming/registry.js +6 -9
  150. package/dist/streaming/registry.js.map +1 -1
  151. package/dist/streaming/setup.d.ts +1 -1
  152. package/dist/streaming/setup.js +44 -21
  153. package/dist/streaming/setup.js.map +1 -1
  154. package/dist/streaming/showInfo.d.ts +7 -6
  155. package/dist/streaming/showInfo.js +156 -36
  156. package/dist/streaming/showInfo.js.map +1 -1
  157. package/dist/streaming/statusEmitter.d.ts +14 -2
  158. package/dist/streaming/statusEmitter.js +22 -4
  159. package/dist/streaming/statusEmitter.js.map +1 -1
  160. package/dist/types/channels.d.ts +31 -0
  161. package/dist/types/config.d.ts +2 -2
  162. package/dist/types/index.d.ts +1 -1
  163. package/dist/types/index.js.map +1 -1
  164. package/dist/types/profiles.d.ts +8 -3
  165. package/dist/types/selection.d.ts +1 -0
  166. package/dist/types/shared.d.ts +1 -1
  167. package/dist/utils/debugFilter.d.ts +0 -5
  168. package/dist/utils/debugFilter.js +5 -11
  169. package/dist/utils/debugFilter.js.map +1 -1
  170. package/dist/utils/delay.d.ts +12 -0
  171. package/dist/utils/delay.js +20 -0
  172. package/dist/utils/delay.js.map +1 -1
  173. package/dist/utils/evaluate.js +6 -11
  174. package/dist/utils/evaluate.js.map +1 -1
  175. package/dist/utils/ffmpeg.d.ts +13 -5
  176. package/dist/utils/ffmpeg.js +32 -11
  177. package/dist/utils/ffmpeg.js.map +1 -1
  178. package/dist/utils/format.d.ts +6 -0
  179. package/dist/utils/format.js +8 -0
  180. package/dist/utils/format.js.map +1 -1
  181. package/dist/utils/index.d.ts +1 -0
  182. package/dist/utils/index.js +1 -0
  183. package/dist/utils/index.js.map +1 -1
  184. package/dist/utils/logEmitter.js +1 -1
  185. package/dist/utils/logEmitter.js.map +1 -1
  186. package/dist/utils/retry.d.ts +15 -9
  187. package/dist/utils/retry.js +7 -23
  188. package/dist/utils/retry.js.map +1 -1
  189. package/dist/utils/timing.d.ts +5 -0
  190. package/dist/utils/timing.js +13 -0
  191. package/dist/utils/timing.js.map +1 -0
  192. package/dist/utils/version.js +2 -2
  193. package/dist/utils/version.js.map +1 -1
  194. package/package.json +5 -5
@@ -25,10 +25,15 @@ import { resolveUrl } from "./probe.js";
25
25
  */
26
26
  // Timeout for segment fetches.
27
27
  const SEGMENT_FETCH_TIMEOUT = 10000;
28
- // Maximum consecutive manifest poll failures before reporting an error.
28
+ // Maximum consecutive manifest poll failures before reporting an error. Client errors (4xx) use this threshold directly. Server errors (5xx) and network timeouts
29
+ // use double the threshold to tolerate transient CDN issues that typically self-resolve within a few retry cycles.
29
30
  const MAX_MANIFEST_FAILURES = 3;
30
31
  // Maximum consecutive segment fetch failures before reporting an error.
31
32
  const MAX_SEGMENT_FAILURES = 5;
33
+ // Manifest poll backoff base delay and cap. On success, the poll interval returns to the base delay (half the target duration, typically ~3s). On failure, the delay
34
+ // doubles on each consecutive failure up to the cap. Jitter of +/-20% prevents multiple streams from retrying in lockstep after a shared CDN outage.
35
+ const MANIFEST_BACKOFF_BASE = 3000;
36
+ const MANIFEST_BACKOFF_CAP = 15000;
32
37
  // Manifest Parsing Helpers.
33
38
  /**
34
39
  * Parses a variant manifest into its metadata and segment list. Handles #EXT-X-MEDIA-SEQUENCE, #EXT-X-TARGETDURATION, #EXT-X-KEY (AES-128 with key rotation),
@@ -267,6 +272,185 @@ function pruneMetadata(meta, activeSegments) {
267
272
  }
268
273
  }
269
274
  }
275
+ // Audio Stream Handler.
276
+ /**
277
+ * Polls the audio variant manifest and fetches new audio segments. Combines the audio manifest poll and segment processing into a single entry point that mirrors the
278
+ * video path. Only called for streams with separate audio renditions.
279
+ *
280
+ * @param ctx - Shared proxy context with lifecycle state, stats, channel name, and error callback.
281
+ * @param audio - Audio-specific tracking state (sequence tracking, metadata, failure counters).
282
+ * @param fetchSegment - Closure-bound segment fetch function that handles HTTP fetch, decryption, and error tracking.
283
+ * @returns True if new audio segments were stored, false otherwise.
284
+ */
285
+ async function pollAudioStream(ctx, audio, fetchSegment) {
286
+ if (ctx.lifecycle.stopped || !audio.variantUrl) {
287
+ return false;
288
+ }
289
+ try {
290
+ const response = await chromeFetch(audio.variantUrl, { signal: AbortSignal.timeout(SEGMENT_FETCH_TIMEOUT) });
291
+ if (!response.ok) {
292
+ audio.consecutiveManifestFailures++;
293
+ ctx.stats.totalFetchErrors++;
294
+ LOG.debug("native:proxy", "Audio manifest poll failed for %s: HTTP %s.", ctx.channelName, response.status);
295
+ if (audio.consecutiveManifestFailures >= MAX_MANIFEST_FAILURES) {
296
+ ctx.lifecycle.errorThresholdReached = true;
297
+ ctx.lifecycle.stopped = true;
298
+ ctx.onError("audio manifest poll failed " + String(audio.consecutiveManifestFailures) + " times");
299
+ }
300
+ return false;
301
+ }
302
+ audio.consecutiveManifestFailures = 0;
303
+ const body = await response.text();
304
+ return await processAudioStream(ctx, audio, body, audio.variantUrl, fetchSegment);
305
+ }
306
+ catch (error) {
307
+ audio.consecutiveManifestFailures++;
308
+ ctx.stats.totalFetchErrors++;
309
+ LOG.debug("native:proxy", "Audio manifest poll failed for %s: %s.", ctx.channelName, String(error));
310
+ if (audio.consecutiveManifestFailures >= MAX_MANIFEST_FAILURES) {
311
+ ctx.lifecycle.errorThresholdReached = true;
312
+ ctx.lifecycle.stopped = true;
313
+ ctx.onError("audio manifest poll error: " + String(error));
314
+ }
315
+ return false;
316
+ }
317
+ }
318
+ /**
319
+ * Parses an audio variant manifest and fetches new audio segments. Handles sequence tracking, high-water mark filtering, token refresh detection, and metadata storage.
320
+ * Playlist generation is handled by the caller after both video and audio segments are stored.
321
+ *
322
+ * @param ctx - Shared proxy context.
323
+ * @param audio - Audio-specific tracking state.
324
+ * @param body - The audio variant manifest text content.
325
+ * @param baseUrl - The audio variant URL for resolving relative segment URLs.
326
+ * @param fetchSegment - Closure-bound segment fetch function.
327
+ * @returns True if new segments were stored, false if the media sequence hadn't advanced.
328
+ */
329
+ async function processAudioStream(ctx, audio, body, baseUrl, fetchSegment) {
330
+ const { mediaSequence, segments, targetDuration } = parseVariantManifest(body, baseUrl);
331
+ audio.lastTargetDuration = targetDuration;
332
+ // Prune old entries from audio fetchedSequences and audio metadata on each poll cycle.
333
+ for (const seq of audio.fetchedSequences) {
334
+ if (seq < mediaSequence) {
335
+ audio.fetchedSequences.delete(seq);
336
+ }
337
+ }
338
+ const audioPruneStream = getStream(ctx.streamId);
339
+ const activeAudioSegments = audioPruneStream ? new Set(audioPruneStream.hls.audioSegments.keys()) : new Set();
340
+ pruneMetadata(audio.metadata, activeAudioSegments);
341
+ // Filter audio segments using the same high-water mark and token refresh detection as the video path.
342
+ let newSegments;
343
+ if (audio.highWaterSequence === -1) {
344
+ newSegments = segments.slice(-CONFIG.hls.maxSegments);
345
+ }
346
+ else {
347
+ newSegments = segments.filter((s) => (s.sequence > audio.highWaterSequence) && !audio.fetchedSequences.has(s.sequence));
348
+ if ((newSegments.length === 0) && audio.tokenRefreshPending && (segments.length > 0)) {
349
+ LOG.debug("native:proxy", "Audio sequence timeline reset detected for %s after token refresh. Resetting high-water mark from %s.", ctx.channelName, audio.highWaterSequence);
350
+ audio.highWaterSequence = -1;
351
+ audio.tokenRefreshPending = false;
352
+ newSegments = segments.slice(-CONFIG.hls.maxSegments);
353
+ }
354
+ }
355
+ if ((newSegments.length > 0) && audio.tokenRefreshPending) {
356
+ audio.tokenRefreshPending = false;
357
+ }
358
+ if (newSegments.length === 0) {
359
+ return false;
360
+ }
361
+ LOG.debug("native:proxy", "Fetching %s new audio segment(s) for %s (sequence %s).", newSegments.length, ctx.channelName, mediaSequence);
362
+ let storedAny = false;
363
+ for (const seg of newSegments) {
364
+ if (ctx.lifecycle.stopped) {
365
+ break;
366
+ }
367
+ // eslint-disable-next-line no-await-in-loop
368
+ const segmentData = await fetchSegment(audio.segmentTracker, seg.url, seg.sequence, seg.ivHex, seg.keyUrl);
369
+ if (!segmentData) {
370
+ continue;
371
+ }
372
+ const filename = "audio" + String(audio.segmentIndex) + ".ts";
373
+ storeAudioSegment(ctx.streamId, filename, segmentData);
374
+ audio.fetchedSequences.add(seg.sequence);
375
+ audio.highWaterSequence = Math.max(audio.highWaterSequence, seg.sequence);
376
+ storeSegmentMetadata(audio.metadata, filename, seg);
377
+ storedAny = true;
378
+ audio.segmentIndex++;
379
+ ctx.stats.totalSegmentsFetched++;
380
+ LOG.debug("native:proxy", "Stored %s (%s bytes, seq %s) for %s.", filename, segmentData.length, seg.sequence, ctx.channelName);
381
+ }
382
+ return storedAny;
383
+ }
384
+ // Composite Playlist Builder.
385
+ /**
386
+ * Builds a composite playlist with fMP4 preroll entries and MPEG-TS real entries. Uses the same compositor and builder as the capture path's generatePlaylist(),
387
+ * ensuring identical windowing behavior (maxPrerollInWindow cap, progressive falloff). The DISCONTINUITY tag at the preroll-to-real boundary signals the container
388
+ * format change (fMP4 → MPEG-TS), which is spec-compliant per RFC 8216 Section 4.3.3.3. VERSION:7 is used to support EXT-X-MAP for the preroll init segment;
389
+ * after preroll entries fall off the window, VERSION:7 remains but is backward-compatible with the MPEG-TS entries.
390
+ *
391
+ * @param options - Composite playlist configuration with segment data, preroll settings, and composite tracking state.
392
+ * @returns The formatted composite m3u8 playlist string.
393
+ */
394
+ function buildCompositePlaylist(options) {
395
+ const { composite, prerollBaseUrl, prerollCodec, prerollSegmentCount, segmentEntries, segmentIndex, targetDuration, videoMetadata } = options;
396
+ // Compute the sliding window start index via the compositor. The three-way max prevents negative indices, enforces the sliding window rule, and caps preroll
397
+ // entries at maxPrerollInWindow to force clients past preroll toward the live edge.
398
+ const realSegmentCount = segmentEntries.length;
399
+ const startIndex = computePrerollWindow({
400
+ currentSegmentIndex: segmentIndex,
401
+ maxSegments: CONFIG.hls.maxSegments,
402
+ prerollSegmentCount,
403
+ realSegmentCount
404
+ });
405
+ // Build fMP4 preroll entries for preroll indices still in the window. These reference the global /preroll/ routes with absolute URLs and .m4s extension.
406
+ let prerollEntries = [];
407
+ if (startIndex < prerollSegmentCount) {
408
+ prerollEntries = buildPrerollEntries({ baseUrl: prerollBaseUrl, codec: prerollCodec, extension: ".m4s", prerollSegmentCount, startIndex });
409
+ }
410
+ // Build real MPEG-TS entries from the video metadata maps via the shared helper.
411
+ const realEntries = segmentEntries.map((filename) => buildEntryFromMetadata(filename, videoMetadata, targetDuration));
412
+ // Mark the preroll-to-real boundary on the first real entry when preroll entries are present in the window. This is a playlist-level concern (the stitching of
413
+ // preroll before real content), not a segment-level property — so it's applied here in the composite builder rather than injected into videoMetadata at segment
414
+ // storage time. This keeps the metadata clean (upstream discontinuities only) and avoids a stray DISCONTINUITY tag on fast streams where the composite never
415
+ // activates.
416
+ if ((prerollEntries.length > 0) && (realEntries.length > 0)) {
417
+ realEntries[0].discontinuity = true;
418
+ }
419
+ // Bootstrap the composite discontinuity tracker on the first call. This captures any upstream discontinuities that occurred before the composite path activated
420
+ // (e.g., the proxy ran in non-composite mode while the preroll timer hadn't fired). The Map has filenames of currently-stored discontinuity segments; the counter
421
+ // includes historical ones that pruned out of the Map. The difference becomes a fixed offset for segments we can never recover by filename.
422
+ if (!composite.seeded) {
423
+ for (const filename of videoMetadata.discontinuities.keys()) {
424
+ composite.discontinuities.add(filename);
425
+ }
426
+ composite.baseOffset = videoMetadata.totalDiscontinuities - videoMetadata.discontinuities.size;
427
+ composite.seeded = true;
428
+ }
429
+ // Compute DISCONTINUITY-SEQUENCE using the composite path's independent discontinuity tracker. For each entry with discontinuity=true (whether from upstream
430
+ // metadata or the synthetic preroll boundary), record its URL in the Set. The Set grows monotonically — once a discontinuity is observed, it's tracked forever.
431
+ // DISCONTINUITY-SEQUENCE = total ever observed (offset + Set size) minus those visible in the current window.
432
+ const entries = [...prerollEntries, ...realEntries];
433
+ let windowDiscontinuities = 0;
434
+ for (const entry of entries) {
435
+ if (entry.discontinuity) {
436
+ composite.discontinuities.add(entry.url);
437
+ windowDiscontinuities++;
438
+ }
439
+ }
440
+ const discSeq = (composite.baseOffset + composite.discontinuities.size) - windowDiscontinuities;
441
+ const discontinuitySequence = (discSeq > 0) ? discSeq : undefined;
442
+ // Determine the initial MAP URI. When the window starts with preroll entries, the preroll init segment (fMP4) is referenced. When the window has moved past all
443
+ // preroll, no MAP is needed (MPEG-TS segments are self-describing). The DISCONTINUITY tag at the preroll-to-real boundary invalidates the MAP per RFC 8216
444
+ // Section 4.3.3.3, so MPEG-TS entries after the boundary carry their codec config inline.
445
+ const initialMapUri = (prerollEntries.length > 0) ? (prerollBaseUrl + "/preroll/" + prerollCodec + "/init.mp4") : undefined;
446
+ return buildPlaylist({
447
+ discontinuitySequence,
448
+ initialMapUri,
449
+ mediaSequence: startIndex,
450
+ targetDuration,
451
+ version: 7
452
+ }, entries);
453
+ }
270
454
  /**
271
455
  * Creates a native HLS proxy that polls a variant manifest, fetches segments, and generates playlists.
272
456
  *
@@ -275,93 +459,116 @@ function pruneMetadata(meta, activeSegments) {
275
459
  */
276
460
  export function createNativeProxy(options) {
277
461
  const { channelName, encryption, keyUrl, onError, streamId } = options;
278
- const { audioVariantUrl: initialAudioVariantUrl, variantUrl: initialVariantUrl } = options;
279
- const hasAudio = initialAudioVariantUrl !== null;
462
+ const hasAudio = options.audioVariantUrl !== null;
280
463
  let activeCdpSession = options.cdpSession;
281
- let audioVariantUrl = initialAudioVariantUrl;
282
- let variantUrl = initialVariantUrl;
283
464
  // Preroll segment index offset. When preroll is ready (prerollSegmentCount > 0), real segments start numbering after the preroll range (e.g., segmentN.ts where
284
465
  // N = prerollSegmentCount). This offset is unconditional — it reserves the index space for preroll regardless of whether the deferred preroll timer fires.
285
466
  // The composite playlist behavior (including preroll entries) is determined dynamically by checking stream.hls.prerollStartTime at playlist generation time.
467
+ const prerollCodec = options.prerollCodec ?? "h264";
286
468
  const prerollSegmentCount = options.prerollSegmentCount ?? 0;
287
- // State.
288
- let pollTimer = null;
289
- let tokenRefreshTimer = null;
290
- let stopped = false;
291
- let segmentIndex = prerollSegmentCount;
292
- let lastMediaSequence = -1;
293
- let lastSegmentSize = null;
294
- let lastSegmentTime = 0;
295
- let consecutiveManifestFailures = 0;
296
- const videoSegmentTracker = { consecutiveFailures: 0, debugLabel: "Segment", label: "segment" };
297
- let errorThresholdReached = false;
298
- let firstPollComplete = false;
299
- let lastAudioTargetDuration = 6;
300
- let lastTargetDuration = 6;
301
- let readinessSignaled = false;
469
+ /* Proxy state. These track segment storage, manifest polling, error thresholds, and playlist generation across the proxy's lifetime. Mutable variables are organized
470
+ * into typed state objects by subsystem (lifecycle, video tracking, audio tracking, composite playlist, statistics) to clarify ownership and interaction boundaries.
471
+ * The ProxyContext bundles immutable references with shared mutable state for extracted module-level functions.
472
+ */
473
+ // Proxy lifecycle state. Controls whether the proxy is running, tracks readiness signaling, and manages poll timing.
474
+ const lifecycle = {
475
+ errorThresholdReached: false,
476
+ firstPollComplete: false,
477
+ manifestBackoffMs: MANIFEST_BACKOFF_BASE,
478
+ pollTimer: null,
479
+ readinessSignaled: false,
480
+ stopped: false,
481
+ tokenRefreshTimer: null
482
+ };
483
+ // Video segment and manifest tracking state.
484
+ const video = {
485
+ consecutiveManifestFailures: 0,
486
+ fetchedSequences: new Set(),
487
+ highWaterSequence: -1,
488
+ lastMediaSequence: -1,
489
+ lastSegmentSize: null,
490
+ lastSegmentTime: 0,
491
+ lastTargetDuration: 6,
492
+ metadata: createSegmentMetadata(),
493
+ segmentIndex: prerollSegmentCount,
494
+ segmentTracker: { consecutiveFailures: 0, debugLabel: "Segment", label: "segment" },
495
+ tokenRefreshPending: false,
496
+ variantUrl: options.variantUrl
497
+ };
498
+ // Audio segment and manifest tracking state for streams with separate audio renditions.
499
+ const audio = {
500
+ consecutiveManifestFailures: 0,
501
+ fetchedSequences: new Set(),
502
+ highWaterSequence: -1,
503
+ lastTargetDuration: 6,
504
+ metadata: createSegmentMetadata(),
505
+ segmentIndex: 0,
506
+ segmentTracker: { consecutiveFailures: 0, debugLabel: "Audio segment", label: "audio segment" },
507
+ tokenRefreshPending: false,
508
+ variantUrl: options.audioVariantUrl
509
+ };
302
510
  // Composite playlist discontinuity tracking. This is the composite path's independent source of truth for DISCONTINUITY-SEQUENCE computation. The Set records URLs
303
511
  // of entries that have had discontinuity=true in any composite playlist (upstream or the synthetic preroll boundary). It grows monotonically — once a discontinuity
304
- // is observed, it's tracked forever. On the first composite call, the Set is bootstrapped from videoMetadata to capture any upstream discontinuities that occurred
512
+ // is observed, it's tracked forever. On the first composite call, the Set is bootstrapped from video.metadata to capture any upstream discontinuities that occurred
305
513
  // before the composite path activated. The offset accounts for historical discontinuities that pruned out of the metadata's Map but are preserved in its counter.
306
514
  // After bootstrap, the Set is self-sufficient. DISCONTINUITY-SEQUENCE = (offset + set.size) - windowDiscontinuities.
307
- const compositeDiscontinuities = new Set();
308
- let compositeBaseOffset = 0;
309
- let compositeSeeded = false;
310
- // Statistics.
311
- let totalFetchErrors = 0;
312
- let totalSegmentsFetched = 0;
313
- let totalTokenRefreshes = 0;
515
+ const composite = {
516
+ baseOffset: 0,
517
+ discontinuities: new Set(),
518
+ seeded: false
519
+ };
520
+ // Cumulative statistics.
521
+ const stats = {
522
+ totalFetchErrors: 0,
523
+ totalSegmentsFetched: 0,
524
+ totalTokenRefreshes: 0
525
+ };
526
+ // Shared context for extracted module-level functions. Bundles immutable references with shared mutable state objects.
527
+ const ctx = { channelName, lifecycle, onError, stats, streamId };
314
528
  // AES-128 decryption key cache. Maps key URLs to their fetched 16-byte keys. Each segment in a manifest can reference a different key URL (key rotation), so we
315
529
  // cache by URL rather than maintaining a single "current key". The coordinator pre-fetches the initial key, which is seeded into the cache here.
316
530
  const keysByUrl = new Map();
317
531
  if (options.prefetchedKey && keyUrl) {
318
532
  keysByUrl.set(keyUrl, options.prefetchedKey);
319
533
  }
320
- // Track fetched media sequence numbers to avoid re-fetching segments. The high-water mark tracks the highest upstream sequence number fetched, used to filter
321
- // out DAI backfill segments that appear as "new" (not in fetchedSequences) but have sequence numbers below what we've already served. Without this, Google DAI
322
- // manifests that interleave multiple session windows cause the proxy to fetch old segments with discontinuous PTS, producing visible glitches in playback.
323
- //
324
- // The tokenRefreshPending flag handles CDNs that reset sequence numbers to 0 on token refresh (e.g., Fox Sports). After a refresh, if the first poll produces
325
- // zero segments above the high-water mark, the sequence timeline has genuinely reset — we clear the high-water mark and re-process the same manifest with the
326
- // tail-fill strategy. If segments do pass (e.g., Fox News DAI where sequences continue), the flag is cleared without any reset.
327
- const fetchedSequences = new Set();
328
- let highWaterSequence = -1;
329
- let tokenRefreshPending = false;
330
- // Per-segment metadata for the video variant. These Maps are keyed by local filename (e.g., "segment0.ts") and track the upstream manifest tags associated with
331
- // each stored segment. The playlist generator reads these to emit faithful HLS output with discontinuity markers, program timestamps, and ad signaling.
332
- const videoMetadata = createSegmentMetadata();
333
- // Audio-specific state for streams with separate audio renditions.
334
- let audioSegmentIndex = 0;
335
- let audioConsecutiveManifestFailures = 0;
336
- const audioSegmentTracker = { consecutiveFailures: 0, debugLabel: "Audio segment", label: "audio segment" };
337
- const audioFetchedSequences = new Set();
338
- let audioHighWaterSequence = -1;
339
- let audioTokenRefreshPending = false;
340
- const audioMetadata = createSegmentMetadata();
534
+ /**
535
+ * Computes the next retry delay with exponential backoff and jitter. Doubles the current backoff (capped at MANIFEST_BACKOFF_CAP) and applies +/-20% jitter.
536
+ * @returns The jittered delay in milliseconds.
537
+ */
538
+ function nextBackoffDelay() {
539
+ lifecycle.manifestBackoffMs = Math.min(lifecycle.manifestBackoffMs * 2, MANIFEST_BACKOFF_CAP);
540
+ const jitter = 0.8 + (Math.random() * 0.4);
541
+ return Math.round(lifecycle.manifestBackoffMs * jitter);
542
+ }
341
543
  /**
342
544
  * Orchestrates the poll cycle: fetches video and audio manifests in parallel, stores segments, and generates playlists after both stores are updated.
343
545
  */
344
546
  async function pollManifest() {
345
- if (stopped) {
547
+ if (lifecycle.stopped) {
346
548
  return;
347
549
  }
348
550
  const pollElapsed = startTimer();
349
551
  try {
350
- const response = await chromeFetch(variantUrl, { signal: AbortSignal.timeout(SEGMENT_FETCH_TIMEOUT) });
552
+ const response = await chromeFetch(video.variantUrl, { signal: AbortSignal.timeout(SEGMENT_FETCH_TIMEOUT) });
351
553
  if (!response.ok) {
352
- consecutiveManifestFailures++;
353
- totalFetchErrors++;
354
- LOG.debug("native:proxy", "Manifest poll failed for %s: HTTP %s.", channelName, response.status);
355
- if (consecutiveManifestFailures >= MAX_MANIFEST_FAILURES) {
356
- errorThresholdReached = true;
357
- stopped = true;
358
- onError("manifest poll failed " + String(consecutiveManifestFailures) + " times");
554
+ video.consecutiveManifestFailures++;
555
+ stats.totalFetchErrors++;
556
+ // Classify the error. Client errors (4xx) typically indicate permanent issues (auth expiry, content removed) that won't self-resolve. Server errors (5xx)
557
+ // are transient CDN issues that usually recover within a few seconds. Client errors use the base threshold; server errors get double the attempts.
558
+ const isClientError = (response.status >= 400) && (response.status < 500);
559
+ const effectiveThreshold = isClientError ? MAX_MANIFEST_FAILURES : (MAX_MANIFEST_FAILURES * 2);
560
+ LOG.debug("native:proxy", "Manifest poll failed for %s: HTTP %s (%s, %s/%s).", channelName, response.status, isClientError ? "client" : "server", video.consecutiveManifestFailures, effectiveThreshold);
561
+ if (video.consecutiveManifestFailures >= effectiveThreshold) {
562
+ lifecycle.errorThresholdReached = true;
563
+ lifecycle.stopped = true;
564
+ onError("manifest poll failed " + String(video.consecutiveManifestFailures) + " times (HTTP " + String(response.status) + ")");
359
565
  return;
360
566
  }
361
- schedulePoll(5000);
567
+ schedulePoll(nextBackoffDelay());
362
568
  return;
363
569
  }
364
- consecutiveManifestFailures = 0;
570
+ video.consecutiveManifestFailures = 0;
571
+ lifecycle.manifestBackoffMs = MANIFEST_BACKOFF_BASE;
365
572
  const body = await response.text();
366
573
  LOG.debug("native:proxy", "Manifest poll for %s completed in %sms.", channelName, pollElapsed());
367
574
  LOG.debug("native:manifest", "Variant manifest for %s:\n%s", channelName, body);
@@ -370,46 +577,50 @@ export function createNativeProxy(options) {
370
577
  let hasNewVideoSegments;
371
578
  let hasNewAudioSegments = false;
372
579
  if (hasAudio) {
373
- [hasNewVideoSegments, hasNewAudioSegments] = await Promise.all([processManifest(body), pollAudioManifest()]);
580
+ [hasNewVideoSegments, hasNewAudioSegments] = await Promise.all([processManifest(body), pollAudioStream(ctx, audio, fetchTrackedSegment)]);
374
581
  }
375
582
  else {
376
583
  hasNewVideoSegments = await processManifest(body);
377
584
  }
378
585
  if (hasNewVideoSegments || hasNewAudioSegments) {
379
- generatePlaylist(lastTargetDuration);
586
+ generatePlaylist(video.lastTargetDuration);
380
587
  if (hasAudio) {
381
- generateAudioPlaylist(lastAudioTargetDuration);
588
+ generateAudioPlaylist(audio.lastTargetDuration);
382
589
  }
383
590
  }
384
591
  // Set firstPollComplete when segments are available. For streams with separate audio, require both video and audio segments before signaling readiness to
385
592
  // prevent publishing a master playlist before both variant playlists have content.
386
- if (!firstPollComplete && (segmentIndex > 0) && (!hasAudio || (audioSegmentIndex > 0))) {
387
- firstPollComplete = true;
593
+ if (!lifecycle.firstPollComplete && (video.segmentIndex > 0) && (!hasAudio || (audio.segmentIndex > 0))) {
594
+ lifecycle.firstPollComplete = true;
388
595
  }
389
596
  // Signal playlist readiness after the first poll completes. Note: initSegmentReady is signaled immediately when the proxy starts (in hls.ts) since native
390
597
  // MPEG-TS has no separate init segment.
391
- if (firstPollComplete && !readinessSignaled) {
392
- readinessSignaled = true;
598
+ if (lifecycle.firstPollComplete && !lifecycle.readinessSignaled) {
599
+ lifecycle.readinessSignaled = true;
393
600
  const stream = getStream(streamId);
394
601
  if (stream) {
395
602
  stream.hls.signalPlaylistReady();
396
603
  }
397
- LOG.debug("native:proxy", "First poll cycle complete for %s. Segment index: %s.", channelName, segmentIndex);
604
+ LOG.debug("native:proxy", "First poll cycle complete for %s. Segment index: %s.", channelName, video.segmentIndex);
398
605
  }
399
606
  }
400
607
  catch (error) {
401
- consecutiveManifestFailures++;
402
- totalFetchErrors++;
403
- LOG.debug("native:proxy", "Manifest poll failed for %s: %s.", channelName, String(error));
404
- if (consecutiveManifestFailures >= MAX_MANIFEST_FAILURES) {
405
- errorThresholdReached = true;
406
- stopped = true;
608
+ // Network errors (timeouts, DNS failures, connection resets) are transient — use the extended threshold and backoff.
609
+ video.consecutiveManifestFailures++;
610
+ stats.totalFetchErrors++;
611
+ LOG.debug("native:proxy", "Manifest poll failed for %s: %s (%s/%s).", channelName, String(error), video.consecutiveManifestFailures, MAX_MANIFEST_FAILURES * 2);
612
+ if (video.consecutiveManifestFailures >= (MAX_MANIFEST_FAILURES * 2)) {
613
+ lifecycle.errorThresholdReached = true;
614
+ lifecycle.stopped = true;
407
615
  onError("manifest poll error: " + String(error));
408
616
  return;
409
617
  }
618
+ schedulePoll(nextBackoffDelay());
619
+ return;
410
620
  }
411
621
  // Schedule the next poll at roughly half the target segment duration for timely detection of new segments.
412
- schedulePoll(3000);
622
+ lifecycle.manifestBackoffMs = MANIFEST_BACKOFF_BASE;
623
+ schedulePoll(MANIFEST_BACKOFF_BASE);
413
624
  }
414
625
  /**
415
626
  * Parses a variant manifest and fetches new segments. Playlist generation is handled by the caller after both video and audio segments are stored.
@@ -418,84 +629,80 @@ export function createNativeProxy(options) {
418
629
  * @returns True if new segments were stored, false if the media sequence hadn't advanced.
419
630
  */
420
631
  async function processManifest(body) {
421
- const { mediaSequence, segments, targetDuration } = parseVariantManifest(body, variantUrl);
422
- lastTargetDuration = targetDuration;
423
- // Prune old entries from the fetchedSequences Set and segment metadata. The provider's media sequence window slides forward, so entries below the current base
424
- // sequence will never be checked again. Without pruning, these structures grow unboundedly over hours of streaming.
425
- if (fetchedSequences.size > 100) {
426
- for (const seq of fetchedSequences) {
427
- if (seq < mediaSequence) {
428
- fetchedSequences.delete(seq);
429
- }
430
- }
431
- }
432
- if (videoMetadata.durations.size > 100) {
433
- const stream = getStream(streamId);
434
- const activeSegments = stream ? new Set(stream.hls.segments.keys()) : new Set();
435
- pruneMetadata(videoMetadata, activeSegments);
436
- }
632
+ const { mediaSequence, segments, targetDuration } = parseVariantManifest(body, video.variantUrl);
633
+ video.lastTargetDuration = targetDuration;
634
+ // Prune old entries from the fetchedSequences Set and segment metadata on each poll cycle. The provider's media sequence window slides forward, so entries below
635
+ // the current base sequence will never be checked again. Without pruning, these structures grow unboundedly over hours of streaming.
636
+ for (const seq of video.fetchedSequences) {
637
+ if (seq < mediaSequence) {
638
+ video.fetchedSequences.delete(seq);
639
+ }
640
+ }
641
+ const pruneStream = getStream(streamId);
642
+ const activeVideoSegments = pruneStream ? new Set(pruneStream.hls.segments.keys()) : new Set();
643
+ pruneMetadata(video.metadata, activeVideoSegments);
437
644
  // Filter segments to only those that advance the live edge. On the first poll (highWaterSequence === -1), we have no baseline yet, so we take the last
438
645
  // maxSegments entries to fill the initial playlist window. On subsequent polls, we only fetch segments with sequence numbers above the high-water mark. This
439
646
  // filters out DAI backfill segments from interleaved session windows — segments that are "new" (not in fetchedSequences) but have lower sequence numbers than
440
647
  // what we've already served, causing PTS discontinuities and visible playback glitches.
441
648
  let newSegments;
442
- if (highWaterSequence === -1) {
649
+ if (video.highWaterSequence === -1) {
443
650
  // First poll — no baseline. Take the tail of the manifest to fill the playlist window.
444
651
  newSegments = segments.slice(-CONFIG.hls.maxSegments);
445
652
  }
446
653
  else {
447
654
  // Subsequent polls — only fetch segments that advance past the high-water mark.
448
- newSegments = segments.filter((s) => (s.sequence > highWaterSequence) && !fetchedSequences.has(s.sequence));
655
+ newSegments = segments.filter((s) => (s.sequence > video.highWaterSequence) && !video.fetchedSequences.has(s.sequence));
449
656
  // Detect CDN sequence timeline reset after a token refresh. Some CDNs (e.g., Fox Sports) create a new session with sequence numbers starting at 0 when
450
657
  // tokens are refreshed. If the first poll after a refresh produces zero segments above the high-water mark, the timeline has genuinely reset — clear the
451
658
  // high-water mark and re-process the same manifest with the tail-fill strategy.
452
- if ((newSegments.length === 0) && tokenRefreshPending && (segments.length > 0)) {
453
- LOG.debug("native:proxy", "Sequence timeline reset detected for %s after token refresh. Resetting high-water mark from %s.", channelName, highWaterSequence);
454
- highWaterSequence = -1;
455
- tokenRefreshPending = false;
659
+ if ((newSegments.length === 0) && video.tokenRefreshPending && (segments.length > 0)) {
660
+ LOG.debug("native:proxy", "Sequence timeline reset detected for %s after token refresh. Resetting high-water mark from %s.", channelName, video.highWaterSequence);
661
+ video.highWaterSequence = -1;
662
+ video.tokenRefreshPending = false;
456
663
  newSegments = segments.slice(-CONFIG.hls.maxSegments);
457
664
  }
458
665
  }
459
666
  // Clear the token refresh flag on the first poll that produces segments, whether via normal filtering or after a reset detection.
460
- if ((newSegments.length > 0) && tokenRefreshPending) {
461
- tokenRefreshPending = false;
667
+ if ((newSegments.length > 0) && video.tokenRefreshPending) {
668
+ video.tokenRefreshPending = false;
462
669
  }
463
670
  if (newSegments.length === 0) {
464
671
  // No new segments — the media sequence hasn't advanced.
465
- if (lastMediaSequence === mediaSequence) {
672
+ if (video.lastMediaSequence === mediaSequence) {
466
673
  LOG.debug("native:proxy", "No new segments for %s (sequence still %s).", channelName, mediaSequence);
467
674
  }
468
- lastMediaSequence = mediaSequence;
675
+ video.lastMediaSequence = mediaSequence;
469
676
  return false;
470
677
  }
471
678
  LOG.debug("native:proxy", "Fetching %s new segment(s) for %s (sequence %s).", newSegments.length, channelName, mediaSequence);
472
- lastMediaSequence = mediaSequence;
679
+ video.lastMediaSequence = mediaSequence;
473
680
  // Fetch and store each new segment sequentially. Track whether at least one segment was stored so the caller only generates playlists when data actually changed.
474
681
  let storedAny = false;
475
682
  for (const seg of newSegments) {
476
- if (stopped) {
683
+ if (lifecycle.stopped) {
477
684
  break;
478
685
  }
479
686
  // eslint-disable-next-line no-await-in-loop
480
- const segmentData = await fetchTrackedSegment(videoSegmentTracker, seg.url, seg.sequence, seg.ivHex, seg.keyUrl);
687
+ const segmentData = await fetchTrackedSegment(video.segmentTracker, seg.url, seg.sequence, seg.ivHex, seg.keyUrl);
481
688
  if (!segmentData) {
482
689
  continue;
483
690
  }
484
- const filename = "segment" + String(segmentIndex) + ".ts";
691
+ const filename = "segment" + String(video.segmentIndex) + ".ts";
485
692
  // Check segment count before store to detect rotation (oldest segment evicted to enforce maxSegments limit).
486
693
  const stream = getStream(streamId);
487
694
  const countBefore = stream?.hls.segments.size ?? 0;
488
695
  storeSegment(streamId, filename, segmentData);
489
- fetchedSequences.add(seg.sequence);
490
- highWaterSequence = Math.max(highWaterSequence, seg.sequence);
491
- storeSegmentMetadata(videoMetadata, filename, seg);
696
+ video.fetchedSequences.add(seg.sequence);
697
+ video.highWaterSequence = Math.max(video.highWaterSequence, seg.sequence);
698
+ storeSegmentMetadata(video.metadata, filename, seg);
492
699
  storedAny = true;
493
- lastSegmentSize = segmentData.length;
494
- lastSegmentTime = Date.now();
495
- segmentIndex++;
496
- totalSegmentsFetched++;
700
+ video.lastSegmentSize = segmentData.length;
701
+ video.lastSegmentTime = Date.now();
702
+ video.segmentIndex++;
703
+ stats.totalSegmentsFetched++;
497
704
  // Log the first segment fetch latency for timing diagnostics.
498
- if (totalSegmentsFetched === 1) {
705
+ if (stats.totalSegmentsFetched === 1) {
499
706
  LOG.debug("timing:native", "First segment fetched for %s (%s bytes).", channelName, segmentData.length);
500
707
  }
501
708
  // Detect segment rotation — if the count didn't increase, the oldest segment was evicted.
@@ -562,10 +769,10 @@ export function createNativeProxy(options) {
562
769
  const data = await fetchAndDecryptSegment(url, sequence, ivHex, segKeyUrl);
563
770
  if (!data) {
564
771
  tracker.consecutiveFailures++;
565
- totalFetchErrors++;
772
+ stats.totalFetchErrors++;
566
773
  if (tracker.consecutiveFailures >= MAX_SEGMENT_FAILURES) {
567
- errorThresholdReached = true;
568
- stopped = true;
774
+ lifecycle.errorThresholdReached = true;
775
+ lifecycle.stopped = true;
569
776
  onError(tracker.label + " fetch failed " + String(tracker.consecutiveFailures) + " times");
570
777
  }
571
778
  return null;
@@ -575,119 +782,16 @@ export function createNativeProxy(options) {
575
782
  }
576
783
  catch (error) {
577
784
  tracker.consecutiveFailures++;
578
- totalFetchErrors++;
785
+ stats.totalFetchErrors++;
579
786
  LOG.debug("native:proxy", "%s fetch failed for %s: %s.", tracker.debugLabel, channelName, String(error));
580
787
  if (tracker.consecutiveFailures >= MAX_SEGMENT_FAILURES) {
581
- errorThresholdReached = true;
582
- stopped = true;
788
+ lifecycle.errorThresholdReached = true;
789
+ lifecycle.stopped = true;
583
790
  onError(tracker.label + " fetch error: " + String(error));
584
791
  }
585
792
  return null;
586
793
  }
587
794
  }
588
- /**
589
- * Polls the audio variant manifest and fetches new audio segments. Returns true if new segments were stored. Only called for streams with separate audio
590
- * renditions.
591
- */
592
- async function pollAudioManifest() {
593
- if (stopped || !audioVariantUrl) {
594
- return false;
595
- }
596
- try {
597
- const response = await chromeFetch(audioVariantUrl, { signal: AbortSignal.timeout(SEGMENT_FETCH_TIMEOUT) });
598
- if (!response.ok) {
599
- audioConsecutiveManifestFailures++;
600
- totalFetchErrors++;
601
- LOG.debug("native:proxy", "Audio manifest poll failed for %s: HTTP %s.", channelName, response.status);
602
- if (audioConsecutiveManifestFailures >= MAX_MANIFEST_FAILURES) {
603
- errorThresholdReached = true;
604
- stopped = true;
605
- onError("audio manifest poll failed " + String(audioConsecutiveManifestFailures) + " times");
606
- }
607
- return false;
608
- }
609
- audioConsecutiveManifestFailures = 0;
610
- const body = await response.text();
611
- return await processAudioManifest(body, audioVariantUrl);
612
- }
613
- catch (error) {
614
- audioConsecutiveManifestFailures++;
615
- totalFetchErrors++;
616
- LOG.debug("native:proxy", "Audio manifest poll failed for %s: %s.", channelName, String(error));
617
- if (audioConsecutiveManifestFailures >= MAX_MANIFEST_FAILURES) {
618
- errorThresholdReached = true;
619
- stopped = true;
620
- onError("audio manifest poll error: " + String(error));
621
- }
622
- return false;
623
- }
624
- }
625
- /**
626
- * Parses an audio variant manifest and fetches new audio segments. Playlist generation is handled by the caller after both video and audio segments are stored.
627
- *
628
- * @param body - The audio variant manifest text content.
629
- * @param baseUrl - The audio variant URL for resolving relative segment URLs.
630
- * @returns True if new segments were stored, false if the media sequence hadn't advanced.
631
- */
632
- async function processAudioManifest(body, baseUrl) {
633
- const { mediaSequence, segments, targetDuration } = parseVariantManifest(body, baseUrl);
634
- lastAudioTargetDuration = targetDuration;
635
- // Prune old entries from audioFetchedSequences.
636
- if (audioFetchedSequences.size > 100) {
637
- for (const seq of audioFetchedSequences) {
638
- if (seq < mediaSequence) {
639
- audioFetchedSequences.delete(seq);
640
- }
641
- }
642
- }
643
- if (audioMetadata.durations.size > 100) {
644
- const stream = getStream(streamId);
645
- const activeSegments = stream ? new Set(stream.hls.audioSegments.keys()) : new Set();
646
- pruneMetadata(audioMetadata, activeSegments);
647
- }
648
- // Filter audio segments using the same high-water mark and token refresh detection as the video path.
649
- let newSegments;
650
- if (audioHighWaterSequence === -1) {
651
- newSegments = segments.slice(-CONFIG.hls.maxSegments);
652
- }
653
- else {
654
- newSegments = segments.filter((s) => (s.sequence > audioHighWaterSequence) && !audioFetchedSequences.has(s.sequence));
655
- if ((newSegments.length === 0) && audioTokenRefreshPending && (segments.length > 0)) {
656
- LOG.debug("native:proxy", "Audio sequence timeline reset detected for %s after token refresh. Resetting high-water mark from %s.", channelName, audioHighWaterSequence);
657
- audioHighWaterSequence = -1;
658
- audioTokenRefreshPending = false;
659
- newSegments = segments.slice(-CONFIG.hls.maxSegments);
660
- }
661
- }
662
- if ((newSegments.length > 0) && audioTokenRefreshPending) {
663
- audioTokenRefreshPending = false;
664
- }
665
- if (newSegments.length === 0) {
666
- return false;
667
- }
668
- LOG.debug("native:proxy", "Fetching %s new audio segment(s) for %s (sequence %s).", newSegments.length, channelName, mediaSequence);
669
- let storedAny = false;
670
- for (const seg of newSegments) {
671
- if (stopped) {
672
- break;
673
- }
674
- // eslint-disable-next-line no-await-in-loop
675
- const segmentData = await fetchTrackedSegment(audioSegmentTracker, seg.url, seg.sequence, seg.ivHex, seg.keyUrl);
676
- if (!segmentData) {
677
- continue;
678
- }
679
- const filename = "audio" + String(audioSegmentIndex) + ".ts";
680
- storeAudioSegment(streamId, filename, segmentData);
681
- audioFetchedSequences.add(seg.sequence);
682
- audioHighWaterSequence = Math.max(audioHighWaterSequence, seg.sequence);
683
- storeSegmentMetadata(audioMetadata, filename, seg);
684
- storedAny = true;
685
- audioSegmentIndex++;
686
- totalSegmentsFetched++;
687
- LOG.debug("native:proxy", "Stored %s (%s bytes, seq %s) for %s.", filename, segmentData.length, seg.sequence, channelName);
688
- }
689
- return storedAny;
690
- }
691
795
  /**
692
796
  * Generates playlists from the stored segments. For streams without separate audio, generates a single variant playlist. When preroll is active (muxed audio only),
693
797
  * produces a composite playlist with fMP4 preroll entries and MPEG-TS real entries bridged by a DISCONTINUITY tag. The preroll entries use the same fMP4 segments
@@ -707,17 +811,26 @@ export function createNativeProxy(options) {
707
811
  // Composite playlist with fMP4 preroll entries + MPEG-TS real entries. The prerollStartTime check ensures we only include preroll entries when the deferred
708
812
  // timer has fired and the client is actually watching preroll. Without this check, fast native streams (where real content arrives before the preroll delay)
709
813
  // would include unnecessary preroll entries. The compositor handles the sliding window with the maxPrerollInWindow cap.
710
- updatePlaylist(streamId, buildCompositePlaylist(segmentEntries, targetDuration, stream.hls.prerollBaseUrl));
814
+ updatePlaylist(streamId, buildCompositePlaylist({
815
+ composite,
816
+ prerollBaseUrl: stream.hls.prerollBaseUrl,
817
+ prerollCodec,
818
+ prerollSegmentCount,
819
+ segmentEntries,
820
+ segmentIndex: video.segmentIndex,
821
+ targetDuration,
822
+ videoMetadata: video.metadata
823
+ }));
711
824
  }
712
825
  else {
713
826
  // No preroll active — standard variant playlist. The segment index may still be offset (starting at prerollSegmentCount) to reserve the index space, but no
714
827
  // preroll entries are included.
715
- updatePlaylist(streamId, buildVariantPlaylist(segmentEntries, videoMetadata, "segment", targetDuration));
828
+ updatePlaylist(streamId, buildVariantPlaylist(segmentEntries, video.metadata, "segment", targetDuration));
716
829
  }
717
830
  }
718
831
  else {
719
832
  // Separate audio — generate video variant playlist and master playlist. Preroll is not supported for separate audio streams (preroll is muxed).
720
- updateVideoPlaylist(streamId, buildVariantPlaylist(segmentEntries, videoMetadata, "segment", targetDuration));
833
+ updateVideoPlaylist(streamId, buildVariantPlaylist(segmentEntries, video.metadata, "segment", targetDuration));
721
834
  // Estimate bandwidth from stored segment sizes and durations. Sum total bytes and total duration across video segments, then convert to bits per second.
722
835
  // Falls back to 5 Mbps when no duration data is available (first segment before durations are populated).
723
836
  let bandwidth = 5000000;
@@ -725,7 +838,7 @@ export function createNativeProxy(options) {
725
838
  let totalDuration = 0;
726
839
  for (const filename of segmentEntries) {
727
840
  const size = stream.hls.segments.get(filename)?.length ?? 0;
728
- const duration = videoMetadata.durations.get(filename);
841
+ const duration = video.metadata.durations.get(filename);
729
842
  if (duration) {
730
843
  totalBytes += size;
731
844
  totalDuration += duration;
@@ -743,76 +856,6 @@ export function createNativeProxy(options) {
743
856
  }
744
857
  LOG.debug("native:proxy", "Playlist generated for %s with %s segment(s), target duration %ss.", channelName, segmentEntries.length, targetDuration);
745
858
  }
746
- /**
747
- * Builds a composite playlist with fMP4 preroll entries and MPEG-TS real entries. Uses the same compositor and builder as the capture path's generatePlaylist(),
748
- * ensuring identical windowing behavior (maxPrerollInWindow cap, progressive falloff). The DISCONTINUITY tag at the preroll-to-real boundary signals the container
749
- * format change (fMP4 → MPEG-TS), which is spec-compliant per RFC 8216 Section 4.3.3.3. VERSION:7 is used to support EXT-X-MAP for the preroll init segment;
750
- * after preroll entries fall off the window, VERSION:7 remains but is backward-compatible with the MPEG-TS entries.
751
- *
752
- * @param segmentEntries - Ordered list of stored segment filenames from the segment Map.
753
- * @param targetDuration - The #EXT-X-TARGETDURATION value from the provider's manifest.
754
- * @param prerollBaseUrl - The base URL for absolute preroll segment URIs, read dynamically from the stream's HLS state.
755
- * @returns The formatted composite m3u8 playlist string.
756
- */
757
- function buildCompositePlaylist(segmentEntries, targetDuration, prerollBaseUrl) {
758
- // Compute the sliding window start index via the compositor. The three-way max prevents negative indices, enforces the sliding window rule, and caps preroll
759
- // entries at maxPrerollInWindow to force clients past preroll toward the live edge.
760
- const realSegmentCount = segmentEntries.length;
761
- const startIndex = computePrerollWindow({
762
- currentSegmentIndex: segmentIndex,
763
- maxSegments: CONFIG.hls.maxSegments,
764
- prerollSegmentCount,
765
- realSegmentCount
766
- });
767
- // Build fMP4 preroll entries for preroll indices still in the window. These reference the global /preroll/ routes with absolute URLs and .m4s extension.
768
- let prerollEntries = [];
769
- if (startIndex < prerollSegmentCount) {
770
- prerollEntries = buildPrerollEntries({ baseUrl: prerollBaseUrl, extension: ".m4s", prerollSegmentCount, startIndex });
771
- }
772
- // Build real MPEG-TS entries from the video metadata maps via the shared helper.
773
- const realEntries = segmentEntries.map((filename) => buildEntryFromMetadata(filename, videoMetadata, targetDuration));
774
- // Mark the preroll-to-real boundary on the first real entry when preroll entries are present in the window. This is a playlist-level concern (the stitching of
775
- // preroll before real content), not a segment-level property — so it's applied here in the composite builder rather than injected into videoMetadata at segment
776
- // storage time. This keeps the metadata clean (upstream discontinuities only) and avoids a stray DISCONTINUITY tag on fast streams where the composite never
777
- // activates.
778
- if ((prerollEntries.length > 0) && (realEntries.length > 0)) {
779
- realEntries[0].discontinuity = true;
780
- }
781
- // Bootstrap the composite discontinuity tracker on the first call. This captures any upstream discontinuities that occurred before the composite path activated
782
- // (e.g., the proxy ran in non-composite mode while the preroll timer hadn't fired). The Map has filenames of currently-stored discontinuity segments; the counter
783
- // includes historical ones that pruned out of the Map. The difference becomes a fixed offset for segments we can never recover by filename.
784
- if (!compositeSeeded) {
785
- for (const filename of videoMetadata.discontinuities.keys()) {
786
- compositeDiscontinuities.add(filename);
787
- }
788
- compositeBaseOffset = videoMetadata.totalDiscontinuities - videoMetadata.discontinuities.size;
789
- compositeSeeded = true;
790
- }
791
- // Compute DISCONTINUITY-SEQUENCE using the composite path's independent discontinuity tracker. For each entry with discontinuity=true (whether from upstream
792
- // metadata or the synthetic preroll boundary), record its URL in the Set. The Set grows monotonically — once a discontinuity is observed, it's tracked forever.
793
- // DISCONTINUITY-SEQUENCE = total ever observed (offset + Set size) minus those visible in the current window.
794
- const entries = [...prerollEntries, ...realEntries];
795
- let windowDiscontinuities = 0;
796
- for (const entry of entries) {
797
- if (entry.discontinuity) {
798
- compositeDiscontinuities.add(entry.url);
799
- windowDiscontinuities++;
800
- }
801
- }
802
- const discSeq = (compositeBaseOffset + compositeDiscontinuities.size) - windowDiscontinuities;
803
- const discontinuitySequence = (discSeq > 0) ? discSeq : undefined;
804
- // Determine the initial MAP URI. When the window starts with preroll entries, the preroll init segment (fMP4) is referenced. When the window has moved past all
805
- // preroll, no MAP is needed (MPEG-TS segments are self-describing). The DISCONTINUITY tag at the preroll-to-real boundary invalidates the MAP per RFC 8216
806
- // Section 4.3.3.3, so MPEG-TS entries after the boundary carry their codec config inline.
807
- const initialMapUri = (prerollEntries.length > 0) ? (prerollBaseUrl + "/preroll/init.mp4") : undefined;
808
- return buildPlaylist({
809
- discontinuitySequence,
810
- initialMapUri,
811
- mediaSequence: startIndex,
812
- targetDuration,
813
- version: 7
814
- }, entries);
815
- }
816
859
  /**
817
860
  * Generates the audio variant playlist from stored audio segments.
818
861
  *
@@ -824,7 +867,7 @@ export function createNativeProxy(options) {
824
867
  return;
825
868
  }
826
869
  const audioEntries = Array.from(stream.hls.audioSegments.keys());
827
- updateAudioPlaylist(streamId, buildVariantPlaylist(audioEntries, audioMetadata, "audio", targetDuration));
870
+ updateAudioPlaylist(streamId, buildVariantPlaylist(audioEntries, audio.metadata, "audio", targetDuration));
828
871
  }
829
872
  /**
830
873
  * Schedules the next manifest poll after a delay.
@@ -832,54 +875,54 @@ export function createNativeProxy(options) {
832
875
  * @param delayMs - Delay in milliseconds before the next poll.
833
876
  */
834
877
  function schedulePoll(delayMs) {
835
- if (stopped) {
878
+ if (lifecycle.stopped) {
836
879
  return;
837
880
  }
838
- pollTimer = setTimeout(() => {
881
+ lifecycle.pollTimer = setTimeout(() => {
839
882
  void pollManifest();
840
883
  }, delayMs);
841
884
  }
842
885
  return {
843
- getConsecutiveErrors: () => videoSegmentTracker.consecutiveFailures + consecutiveManifestFailures + audioSegmentTracker.consecutiveFailures +
844
- audioConsecutiveManifestFailures,
845
- getLastSegmentSize: () => lastSegmentSize,
846
- getLastSegmentTime: () => lastSegmentTime,
847
- getSegmentIndex: () => segmentIndex,
886
+ getConsecutiveErrors: () => video.segmentTracker.consecutiveFailures + video.consecutiveManifestFailures +
887
+ audio.segmentTracker.consecutiveFailures + audio.consecutiveManifestFailures,
888
+ getLastSegmentSize: () => video.lastSegmentSize,
889
+ getLastSegmentTime: () => video.lastSegmentTime,
890
+ getSegmentIndex: () => video.segmentIndex,
848
891
  getStats: () => ({
849
- fetchErrors: totalFetchErrors,
850
- segmentsFetched: totalSegmentsFetched,
851
- tokenRefreshes: totalTokenRefreshes
892
+ fetchErrors: stats.totalFetchErrors,
893
+ segmentsFetched: stats.totalSegmentsFetched,
894
+ tokenRefreshes: stats.totalTokenRefreshes
852
895
  }),
853
- getTargetDuration: () => lastTargetDuration,
854
- hasErrored: () => errorThresholdReached,
855
- isStopped: () => stopped,
896
+ getTargetDuration: () => video.lastTargetDuration,
897
+ hasErrored: () => lifecycle.errorThresholdReached,
898
+ isStopped: () => lifecycle.stopped,
856
899
  setTokenRefreshTimer: (timer) => {
857
- tokenRefreshTimer = timer;
900
+ lifecycle.tokenRefreshTimer = timer;
858
901
  },
859
902
  start: () => {
860
903
  LOG.debug("native:proxy", "Starting native proxy for %s (%s).", channelName, encryption);
861
904
  void pollManifest();
862
905
  },
863
906
  stop: () => {
864
- stopped = true;
865
- if (pollTimer) {
866
- clearTimeout(pollTimer);
867
- pollTimer = null;
907
+ lifecycle.stopped = true;
908
+ if (lifecycle.pollTimer) {
909
+ clearTimeout(lifecycle.pollTimer);
910
+ lifecycle.pollTimer = null;
868
911
  }
869
912
  // Cancel the pending token refresh timer to prevent fire-after-termination. Without this, the timer fires on a stopped proxy and attempts to navigate a
870
913
  // potentially closed or reused page.
871
- if (tokenRefreshTimer) {
872
- clearTimeout(tokenRefreshTimer);
873
- tokenRefreshTimer = null;
914
+ if (lifecycle.tokenRefreshTimer) {
915
+ clearTimeout(lifecycle.tokenRefreshTimer);
916
+ lifecycle.tokenRefreshTimer = null;
874
917
  }
875
918
  // Clean up the CDP session from manifest interception to prevent session leaks.
876
919
  removeManifestInterceptor(activeCdpSession);
877
920
  LOG.debug("native:proxy", "Stopped native proxy for %s.", channelName);
878
921
  },
879
922
  updateAudioVariantUrl: (newUrl) => {
880
- audioVariantUrl = newUrl;
881
- audioFetchedSequences.clear();
882
- audioTokenRefreshPending = true;
923
+ audio.variantUrl = newUrl;
924
+ audio.fetchedSequences.clear();
925
+ audio.tokenRefreshPending = true;
883
926
  LOG.debug("native:proxy", "Audio variant URL updated for %s.", channelName);
884
927
  },
885
928
  updateCdpSession: (session) => {
@@ -888,15 +931,15 @@ export function createNativeProxy(options) {
888
931
  activeCdpSession = session;
889
932
  },
890
933
  updateVariantUrl: (newUrl) => {
891
- variantUrl = newUrl;
892
- totalTokenRefreshes++;
934
+ video.variantUrl = newUrl;
935
+ stats.totalTokenRefreshes++;
893
936
  // Clear the fetched set so sequence numbers from the old CDN session don't cause segments in the new session to be incorrectly filtered as "already fetched."
894
937
  // The high-water mark is intentionally preserved — it's the same live stream with the same sequence timeline, just with fresh auth tokens. Resetting it would
895
938
  // allow the proxy to re-fetch segments the client already consumed, causing PTS discontinuities and visible playback glitches. The tokenRefreshPending flag
896
939
  // enables processManifest to detect genuine sequence timeline resets (e.g., Fox Sports) on the first poll after refresh.
897
- fetchedSequences.clear();
898
- lastMediaSequence = -1;
899
- tokenRefreshPending = true;
940
+ video.fetchedSequences.clear();
941
+ video.lastMediaSequence = -1;
942
+ video.tokenRefreshPending = true;
900
943
  LOG.debug("native:proxy", "Variant URL updated for %s. Segment tracking reset.", channelName);
901
944
  }
902
945
  };