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.
- package/dist/browser/cdp.js +1 -1
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +10 -34
- package/dist/browser/channelSelection.js +19 -96
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/display.d.ts +19 -0
- package/dist/browser/display.js +16 -11
- package/dist/browser/display.js.map +1 -1
- package/dist/browser/index.d.ts +9 -46
- package/dist/browser/index.js +136 -191
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.d.ts +65 -0
- package/dist/browser/login.js +193 -0
- package/dist/browser/login.js.map +1 -0
- package/dist/browser/precaching.js +12 -0
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
- package/dist/browser/tuning/comcastPolymer.js +655 -0
- package/dist/browser/tuning/comcastPolymer.js.map +1 -0
- package/dist/browser/tuning/cox.d.ts +2 -0
- package/dist/browser/tuning/cox.js +14 -0
- package/dist/browser/tuning/cox.js.map +1 -0
- package/dist/browser/tuning/directv.js +1 -1
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +2 -2
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.js +1 -1
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.js +1 -1
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/shared.d.ts +46 -0
- package/dist/browser/tuning/shared.js +100 -0
- package/dist/browser/tuning/shared.js.map +1 -0
- package/dist/browser/tuning/sling.js +1 -1
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/browser/tuning/spectrum.js +1 -1
- package/dist/browser/tuning/spectrum.js.map +1 -1
- package/dist/browser/tuning/thumbnailRow.js +1 -1
- package/dist/browser/tuning/thumbnailRow.js.map +1 -1
- package/dist/browser/tuning/tileClick.js +1 -1
- package/dist/browser/tuning/tileClick.js.map +1 -1
- package/dist/browser/tuning/xfinity.js +10 -603
- package/dist/browser/tuning/xfinity.js.map +1 -1
- package/dist/browser/tuning/youtubeTv.js +1 -1
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +18 -0
- package/dist/browser/video.js +69 -107
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.d.ts +2 -2
- package/dist/channels/index.js +2075 -800
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.js +20 -2
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +29 -64
- package/dist/config/index.js.map +1 -1
- package/dist/config/providerPacks.js +4 -1
- package/dist/config/providerPacks.js.map +1 -1
- package/dist/config/providers.d.ts +12 -5
- package/dist/config/providers.js +110 -94
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.js +15 -9
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +50 -2
- package/dist/config/userChannels.js +212 -10
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +1 -1
- package/dist/config/userConfig.js +2 -15
- package/dist/config/userConfig.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +4 -4
- package/dist/hdhr/channelMap.js.map +1 -1
- package/dist/index.js +9 -15
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +3 -0
- package/dist/native/index.js +2 -1
- package/dist/native/index.js.map +1 -1
- package/dist/native/probe.d.ts +4 -3
- package/dist/native/probe.js +36 -12
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.d.ts +2 -0
- package/dist/native/proxy.js +369 -326
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/components.d.ts +43 -0
- package/dist/routes/components.js +122 -0
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +2 -2
- package/dist/routes/config/channels/index.js +1 -1
- package/dist/routes/config/channels/index.js.map +1 -1
- package/dist/routes/config/channels/routes.js +443 -65
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +76 -0
- package/dist/routes/config/channels/table.js +352 -66
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/index.d.ts +1 -1
- package/dist/routes/config/index.js +1 -1
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/providers.d.ts +6 -6
- package/dist/routes/config/providers.js +69 -95
- package/dist/routes/config/providers.js.map +1 -1
- package/dist/routes/index.d.ts +0 -16
- package/dist/routes/index.js +0 -17
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/providers.js +106 -3
- package/dist/routes/providers.js.map +1 -1
- package/dist/routes/root/content.js +55 -38
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/index.js +2 -1
- package/dist/routes/root/index.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +772 -312
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +596 -544
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/index.d.ts +1 -0
- package/dist/routes/root/scripts/index.js +1 -0
- package/dist/routes/root/scripts/index.js.map +1 -1
- package/dist/routes/root/scripts/shared.d.ts +6 -0
- package/dist/routes/root/scripts/shared.js +391 -0
- package/dist/routes/root/scripts/shared.js.map +1 -0
- package/dist/routes/root/scripts/status.js +36 -21
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +113 -8
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/service/commands.js +2 -1
- package/dist/service/commands.js.map +1 -1
- package/dist/streaming/fmp4Segmenter.d.ts +2 -0
- package/dist/streaming/fmp4Segmenter.js +7 -4
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.js +269 -188
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.d.ts +9 -3
- package/dist/streaming/hlsResume.js +13 -6
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/hlsSegments.d.ts +1 -2
- package/dist/streaming/hlsSegments.js +48 -46
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.js +8 -12
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.js +625 -585
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mpegts.js +1 -1
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/preroll.d.ts +40 -20
- package/dist/streaming/preroll.js +147 -86
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/recovery.d.ts +0 -6
- package/dist/streaming/recovery.js +0 -8
- package/dist/streaming/recovery.js.map +1 -1
- package/dist/streaming/registry.d.ts +36 -9
- package/dist/streaming/registry.js +6 -9
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +1 -1
- package/dist/streaming/setup.js +44 -21
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.d.ts +7 -6
- package/dist/streaming/showInfo.js +156 -36
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +14 -2
- package/dist/streaming/statusEmitter.js +22 -4
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +31 -0
- package/dist/types/config.d.ts +2 -2
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +8 -3
- package/dist/types/selection.d.ts +1 -0
- package/dist/types/shared.d.ts +1 -1
- package/dist/utils/debugFilter.d.ts +0 -5
- package/dist/utils/debugFilter.js +5 -11
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/delay.d.ts +12 -0
- package/dist/utils/delay.js +20 -0
- package/dist/utils/delay.js.map +1 -1
- package/dist/utils/evaluate.js +6 -11
- package/dist/utils/evaluate.js.map +1 -1
- package/dist/utils/ffmpeg.d.ts +13 -5
- package/dist/utils/ffmpeg.js +32 -11
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +8 -0
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logEmitter.js +1 -1
- package/dist/utils/logEmitter.js.map +1 -1
- package/dist/utils/retry.d.ts +15 -9
- package/dist/utils/retry.js +7 -23
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/timing.d.ts +5 -0
- package/dist/utils/timing.js +13 -0
- package/dist/utils/timing.js.map +1 -0
- package/dist/utils/version.js +2 -2
- package/dist/utils/version.js.map +1 -1
- package/package.json +5 -5
package/dist/native/proxy.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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(
|
|
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),
|
|
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(
|
|
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 || (
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
|
424
|
-
// sequence will never be checked again. Without pruning, these structures grow unboundedly over hours of streaming.
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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 =
|
|
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,
|
|
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: () =>
|
|
844
|
-
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
};
|