prismcast 1.5.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -28
- package/dist/app.d.ts +5 -0
- package/dist/app.js +63 -5
- package/dist/app.js.map +1 -1
- 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 +31 -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 +31 -54
- package/dist/browser/index.js +334 -272
- 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 +15 -3
- 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 +14 -1
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +14 -2
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.js +12 -1
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.js +24 -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 +35 -5
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/browser/tuning/spectrum.js +13 -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.d.ts +2 -0
- package/dist/browser/tuning/xfinity.js +14 -0
- package/dist/browser/tuning/xfinity.js.map +1 -0
- package/dist/browser/tuning/youtubeTv.js +15 -1
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +20 -0
- package/dist/browser/video.js +168 -124
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.d.ts +2 -2
- package/dist/channels/index.js +2075 -683
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +28 -28
- package/dist/config/health.js +72 -54
- 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/paths.d.ts +12 -0
- package/dist/config/paths.js +16 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/profiles.d.ts +3 -2
- package/dist/config/profiles.js +45 -34
- package/dist/config/profiles.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 +19 -5
- package/dist/config/providers.js +123 -93
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.d.ts +28 -0
- package/dist/config/sites.js +104 -127
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +53 -2
- package/dist/config/userChannels.js +222 -13
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +2 -1
- package/dist/config/userConfig.js +6 -15
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +0 -1
- package/dist/config/userProfiles.js +18 -10
- package/dist/config/userProfiles.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 +25 -22
- package/dist/index.js.map +1 -1
- package/dist/native/decrypt.d.ts +32 -0
- package/dist/native/decrypt.js +85 -0
- package/dist/native/decrypt.js.map +1 -0
- package/dist/native/index.d.ts +61 -0
- package/dist/native/index.js +342 -0
- package/dist/native/index.js.map +1 -0
- package/dist/native/intercept.d.ts +35 -0
- package/dist/native/intercept.js +184 -0
- package/dist/native/intercept.js.map +1 -0
- package/dist/native/probe.d.ts +49 -0
- package/dist/native/probe.js +294 -0
- package/dist/native/probe.js.map +1 -0
- package/dist/native/proxy.d.ts +54 -0
- package/dist/native/proxy.js +947 -0
- package/dist/native/proxy.js.map +1 -0
- 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 +369 -89
- 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 +73 -99
- package/dist/routes/config/providers.js.map +1 -1
- package/dist/routes/debug.js +5 -5
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/hls.js +12 -2
- package/dist/routes/hls.js.map +1 -1
- package/dist/routes/index.d.ts +0 -15
- package/dist/routes/index.js +2 -16
- 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 +124 -47
- 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 +49 -33
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +117 -10
- 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 +5 -0
- package/dist/streaming/fmp4Segmenter.js +146 -42
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +24 -19
- package/dist/streaming/hls.js +603 -297
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.d.ts +16 -3
- package/dist/streaming/hlsResume.js +35 -8
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/hlsSegments.d.ts +41 -2
- package/dist/streaming/hlsSegments.js +131 -28
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.js +27 -13
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.d.ts +1 -0
- package/dist/streaming/monitor.js +862 -474
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mp4Parser.d.ts +52 -6
- package/dist/streaming/mp4Parser.js +156 -30
- package/dist/streaming/mp4Parser.js.map +1 -1
- package/dist/streaming/mpegts.d.ts +2 -2
- package/dist/streaming/mpegts.js +156 -109
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/playlistBuilder.d.ts +38 -0
- package/dist/streaming/playlistBuilder.js +66 -0
- package/dist/streaming/playlistBuilder.js.map +1 -0
- package/dist/streaming/preroll.d.ts +126 -0
- package/dist/streaming/preroll.js +434 -0
- package/dist/streaming/preroll.js.map +1 -0
- package/dist/streaming/pretune.d.ts +8 -0
- package/dist/streaming/pretune.js +226 -0
- package/dist/streaming/pretune.js.map +1 -0
- 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 +57 -9
- package/dist/streaming/registry.js +26 -7
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +8 -1
- package/dist/streaming/setup.js +71 -27
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.d.ts +29 -4
- package/dist/streaming/showInfo.js +210 -38
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +20 -6
- package/dist/streaming/statusEmitter.js +31 -7
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +32 -0
- package/dist/types/config.d.ts +2 -2
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +13 -1
- package/dist/types/selection.d.ts +5 -1
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +7 -0
- package/dist/utils/chromeFetch.d.ts +20 -0
- package/dist/utils/chromeFetch.js +43 -0
- package/dist/utils/chromeFetch.js.map +1 -0
- package/dist/utils/debugFilter.d.ts +0 -5
- package/dist/utils/debugFilter.js +20 -11
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/delay.d.ts +28 -0
- package/dist/utils/delay.js +35 -1
- 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 +14 -12
- package/dist/utils/ffmpeg.js +114 -134
- 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 +3 -0
- package/dist/utils/index.js +3 -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/pid.d.ts +40 -0
- package/dist/utils/pid.js +83 -0
- package/dist/utils/pid.js.map +1 -0
- 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 +7 -7
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
/* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* proxy.ts: Native HLS proxy — manifest polling, segment fetching, and playlist generation.
|
|
4
|
+
*/
|
|
5
|
+
import { LOG, chromeFetch, startTimer } from "../utils/index.js";
|
|
6
|
+
import { buildPrerollEntries, computePrerollWindow } from "../streaming/preroll.js";
|
|
7
|
+
import { decryptSegment, deriveIvFromSequence, fetchDecryptionKey, parseExplicitIv } from "./decrypt.js";
|
|
8
|
+
import { storeAudioSegment, storeSegment, updateAudioPlaylist, updatePlaylist, updateVideoPlaylist } from "../streaming/hlsSegments.js";
|
|
9
|
+
import { CONFIG } from "../config/index.js";
|
|
10
|
+
import { buildPlaylist } from "../streaming/playlistBuilder.js";
|
|
11
|
+
import { getStream } from "../streaming/registry.js";
|
|
12
|
+
import { removeManifestInterceptor } from "./intercept.js";
|
|
13
|
+
import { resolveUrl } from "./probe.js";
|
|
14
|
+
/* This module implements the native HLS proxy that replaces Chrome screen capture for viable streams. It polls the provider's variant manifest at regular intervals,
|
|
15
|
+
* detects new segments by tracking #EXT-X-MEDIA-SEQUENCE, fetches each segment (optionally decrypting AES-128), stores them in the existing HLS segment system, and
|
|
16
|
+
* generates an HLS playlist that faithfully propagates upstream metadata — discontinuity markers, program timestamps, and SCTE-35 ad signaling (cue-out, cue-in,
|
|
17
|
+
* cue-out-cont).
|
|
18
|
+
*
|
|
19
|
+
* The proxy generates its own playlist rather than rewriting the provider's playlist, which avoids dealing with CDN-relative URLs and provider-specific quirks. However,
|
|
20
|
+
* it preserves all playback-critical tags from the upstream manifest so that downstream consumers (Channels DVR) can correctly handle PTS resets at ad boundaries,
|
|
21
|
+
* synchronize wall-clock time, and detect commercial breaks.
|
|
22
|
+
*
|
|
23
|
+
* Video segments are stored as "segment0.ts", "segment1.ts", etc. For streams with separate audio renditions, audio segments are stored as "audio0.ts",
|
|
24
|
+
* "audio1.ts", etc.
|
|
25
|
+
*/
|
|
26
|
+
// Timeout for segment fetches.
|
|
27
|
+
const SEGMENT_FETCH_TIMEOUT = 10000;
|
|
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.
|
|
30
|
+
const MAX_MANIFEST_FAILURES = 3;
|
|
31
|
+
// Maximum consecutive segment fetch failures before reporting an error.
|
|
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;
|
|
37
|
+
// Manifest Parsing Helpers.
|
|
38
|
+
/**
|
|
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),
|
|
40
|
+
* #EXT-X-DISCONTINUITY, #EXT-X-PROGRAM-DATE-TIME, #EXT-X-CUE-OUT, #EXT-X-CUE-OUT-CONT, #EXT-X-CUE-IN, and #EXTINF + segment URL pairs. Key/IV state and metadata
|
|
41
|
+
* flags are tracked per-segment so key rotation and discontinuities within a single manifest are handled correctly.
|
|
42
|
+
*
|
|
43
|
+
* @param body - The variant manifest text content.
|
|
44
|
+
* @param baseUrl - Base URL for resolving relative segment and key URLs.
|
|
45
|
+
* @returns Parsed metadata and segment list.
|
|
46
|
+
*/
|
|
47
|
+
function parseVariantManifest(body, baseUrl) {
|
|
48
|
+
const lines = body.split("\n");
|
|
49
|
+
// Parse header tags: #EXT-X-MEDIA-SEQUENCE and #EXT-X-TARGETDURATION. The upstream #EXT-X-DISCONTINUITY-SEQUENCE is intentionally ignored — the proxy computes its
|
|
50
|
+
// own value from its local sliding window, which differs from the upstream's window size and rotation rate.
|
|
51
|
+
let mediaSequence = 0;
|
|
52
|
+
let targetDuration = 6;
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const trimmed = line.trim();
|
|
55
|
+
if (trimmed.startsWith("#EXT-X-MEDIA-SEQUENCE:")) {
|
|
56
|
+
mediaSequence = Number(trimmed.split(":")[1]);
|
|
57
|
+
}
|
|
58
|
+
else if (trimmed.startsWith("#EXT-X-TARGETDURATION:")) {
|
|
59
|
+
targetDuration = Number(trimmed.split(":")[1]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Parse segments: each #EXTINF line is followed by the segment URL. Per the HLS spec, each #EXT-X-KEY applies to all subsequent segments until the next
|
|
63
|
+
// #EXT-X-KEY tag. We track the current key URL and IV per segment so key rotation within a single manifest is handled correctly. Metadata tags
|
|
64
|
+
// (#EXT-X-DISCONTINUITY, #EXT-X-PROGRAM-DATE-TIME, #EXT-X-CUE-OUT, #EXT-X-CUE-IN, #EXT-X-CUE-OUT-CONT) are accumulated as pending flags and attached to the
|
|
65
|
+
// next segment.
|
|
66
|
+
const segments = [];
|
|
67
|
+
let currentSequence = mediaSequence;
|
|
68
|
+
let currentManifestKeyUrl = null;
|
|
69
|
+
let currentIvHex = null;
|
|
70
|
+
// Pending metadata flags — accumulated from tags between segments and attached to the next #EXTINF.
|
|
71
|
+
let pendingCueIn = false;
|
|
72
|
+
let pendingCueOut = null;
|
|
73
|
+
let pendingCueOutCont = null;
|
|
74
|
+
let pendingDiscontinuity = false;
|
|
75
|
+
let pendingProgramDateTime = null;
|
|
76
|
+
for (let i = 0; i < lines.length; i++) {
|
|
77
|
+
const trimmed = lines[i].trim();
|
|
78
|
+
if (trimmed.startsWith("#EXT-X-KEY:")) {
|
|
79
|
+
const methodMatch = /METHOD=([A-Za-z0-9-]+)/.exec(trimmed);
|
|
80
|
+
const method = methodMatch ? methodMatch[1].toUpperCase() : "NONE";
|
|
81
|
+
if (method === "AES-128") {
|
|
82
|
+
const uriMatch = /URI="([^"]+)"/.exec(trimmed);
|
|
83
|
+
if (uriMatch) {
|
|
84
|
+
currentManifestKeyUrl = resolveUrl(uriMatch[1], baseUrl);
|
|
85
|
+
}
|
|
86
|
+
const ivMatch = /IV=([0-9a-fA-Fx]+)/.exec(trimmed);
|
|
87
|
+
currentIvHex = ivMatch ? ivMatch[1] : null;
|
|
88
|
+
}
|
|
89
|
+
else if (method === "NONE") {
|
|
90
|
+
currentManifestKeyUrl = null;
|
|
91
|
+
currentIvHex = null;
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (trimmed === "#EXT-X-DISCONTINUITY") {
|
|
96
|
+
pendingDiscontinuity = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (trimmed.startsWith("#EXT-X-PROGRAM-DATE-TIME:")) {
|
|
100
|
+
pendingProgramDateTime = trimmed.slice(25);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (trimmed.startsWith("#EXT-X-CUE-OUT-CONT:")) {
|
|
104
|
+
pendingCueOutCont = trimmed.slice(20);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (trimmed.startsWith("#EXT-X-CUE-OUT:")) {
|
|
108
|
+
pendingCueOut = trimmed.slice(15);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (trimmed === "#EXT-X-CUE-OUT") {
|
|
112
|
+
pendingCueOut = "";
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (trimmed === "#EXT-X-CUE-IN") {
|
|
116
|
+
pendingCueIn = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!trimmed.startsWith("#EXTINF:")) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const durationStr = trimmed.slice(8).split(",")[0];
|
|
123
|
+
const duration = Number(durationStr);
|
|
124
|
+
const segUrl = (i + 1 < lines.length) ? lines[i + 1].trim() : "";
|
|
125
|
+
if (!segUrl || segUrl.startsWith("#")) {
|
|
126
|
+
currentSequence++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
segments.push({
|
|
130
|
+
cueIn: pendingCueIn,
|
|
131
|
+
cueOut: pendingCueOut,
|
|
132
|
+
cueOutCont: pendingCueOutCont,
|
|
133
|
+
discontinuity: pendingDiscontinuity,
|
|
134
|
+
duration,
|
|
135
|
+
ivHex: currentIvHex,
|
|
136
|
+
keyUrl: currentManifestKeyUrl,
|
|
137
|
+
programDateTime: pendingProgramDateTime,
|
|
138
|
+
sequence: currentSequence,
|
|
139
|
+
url: resolveUrl(segUrl, baseUrl)
|
|
140
|
+
});
|
|
141
|
+
// Reset pending flags after attaching them to a segment.
|
|
142
|
+
pendingCueIn = false;
|
|
143
|
+
pendingCueOut = null;
|
|
144
|
+
pendingCueOutCont = null;
|
|
145
|
+
pendingDiscontinuity = false;
|
|
146
|
+
pendingProgramDateTime = null;
|
|
147
|
+
currentSequence++;
|
|
148
|
+
}
|
|
149
|
+
return { mediaSequence, segments, targetDuration };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Builds a variant playlist string from stored segment filenames and their associated metadata. Emits #EXT-X-DISCONTINUITY, #EXT-X-DISCONTINUITY-SEQUENCE,
|
|
153
|
+
* #EXT-X-PROGRAM-DATE-TIME, #EXT-X-CUE-OUT, #EXT-X-CUE-OUT-CONT, and #EXT-X-CUE-IN tags to faithfully represent upstream ad boundaries and PTS resets.
|
|
154
|
+
*
|
|
155
|
+
* @param segmentEntries - Ordered list of segment filenames from the segment Map.
|
|
156
|
+
* @param metadata - Per-segment metadata (durations, timestamps, discontinuities, cue markers).
|
|
157
|
+
* @param prefix - Filename prefix for extracting the sequence index (e.g., "segment" or "audio").
|
|
158
|
+
* @param targetDuration - #EXT-X-TARGETDURATION value.
|
|
159
|
+
* @returns The formatted m3u8 playlist string.
|
|
160
|
+
*/
|
|
161
|
+
function buildVariantPlaylist(segmentEntries, metadata, prefix, targetDuration) {
|
|
162
|
+
// Compute MEDIA-SEQUENCE from the first filename in the window. The filename encodes the local segment index (e.g., "segment5.ts" → 5).
|
|
163
|
+
let mediaSequence = 0;
|
|
164
|
+
if (segmentEntries.length > 0) {
|
|
165
|
+
mediaSequence = Number(segmentEntries[0].replace(prefix, "").replace(".ts", ""));
|
|
166
|
+
}
|
|
167
|
+
// Compute DISCONTINUITY-SEQUENCE: total discontinuities ever observed minus those still visible in the current window. Only provided when > 0 to keep playlists
|
|
168
|
+
// clean for streams that never have discontinuities.
|
|
169
|
+
let windowDiscontinuities = 0;
|
|
170
|
+
for (const filename of segmentEntries) {
|
|
171
|
+
if (metadata.discontinuities.has(filename)) {
|
|
172
|
+
windowDiscontinuities++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const discSeq = metadata.totalDiscontinuities - windowDiscontinuities;
|
|
176
|
+
const discontinuitySequence = (discSeq > 0) ? discSeq : undefined;
|
|
177
|
+
const entries = segmentEntries.map((filename) => buildEntryFromMetadata(filename, metadata, targetDuration));
|
|
178
|
+
return buildPlaylist({ discontinuitySequence, mediaSequence, targetDuration, version: 3 }, entries);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Creates an empty SegmentMetadata instance with fresh Maps and a zeroed discontinuity counter.
|
|
182
|
+
* @returns A new SegmentMetadata instance.
|
|
183
|
+
*/
|
|
184
|
+
function createSegmentMetadata() {
|
|
185
|
+
return {
|
|
186
|
+
cueIns: new Map(),
|
|
187
|
+
cueOutConts: new Map(),
|
|
188
|
+
cueOuts: new Map(),
|
|
189
|
+
discontinuities: new Map(),
|
|
190
|
+
durations: new Map(),
|
|
191
|
+
timestamps: new Map(),
|
|
192
|
+
totalDiscontinuities: 0
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Records per-segment metadata from a parsed upstream segment into a SegmentMetadata instance. Stores duration, upstream program-date-time (when available),
|
|
197
|
+
* discontinuity flag, and SCTE-35 cue markers.
|
|
198
|
+
*
|
|
199
|
+
* @param meta - The metadata instance to update.
|
|
200
|
+
* @param filename - The local segment filename (e.g., "segment0.ts").
|
|
201
|
+
* @param seg - The parsed segment with upstream metadata.
|
|
202
|
+
*/
|
|
203
|
+
function storeSegmentMetadata(meta, filename, seg) {
|
|
204
|
+
meta.durations.set(filename, seg.duration);
|
|
205
|
+
if (seg.programDateTime) {
|
|
206
|
+
meta.timestamps.set(filename, seg.programDateTime);
|
|
207
|
+
}
|
|
208
|
+
if (seg.discontinuity) {
|
|
209
|
+
meta.discontinuities.set(filename, true);
|
|
210
|
+
meta.totalDiscontinuities++;
|
|
211
|
+
}
|
|
212
|
+
if (seg.cueIn) {
|
|
213
|
+
meta.cueIns.set(filename, true);
|
|
214
|
+
}
|
|
215
|
+
if (seg.cueOut !== null) {
|
|
216
|
+
meta.cueOuts.set(filename, seg.cueOut);
|
|
217
|
+
}
|
|
218
|
+
if (seg.cueOutCont !== null) {
|
|
219
|
+
meta.cueOutConts.set(filename, seg.cueOutCont);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Builds a PlaylistSegmentEntry from a segment's metadata. Translates the per-segment Maps (durations, timestamps, discontinuities, cue markers) into the shared entry
|
|
224
|
+
* type used by the playlist builder. Used by both buildVariantPlaylist() and buildCompositePlaylist() to avoid duplicating the field-by-field metadata lookup.
|
|
225
|
+
*
|
|
226
|
+
* @param filename - The segment filename (e.g., "segmentN.ts" where N starts at prerollSegmentCount).
|
|
227
|
+
* @param metadata - The metadata instance to read from.
|
|
228
|
+
* @param defaultDuration - Fallback duration when the metadata Map has no entry for this filename.
|
|
229
|
+
* @returns A PlaylistSegmentEntry with all applicable metadata fields set.
|
|
230
|
+
*/
|
|
231
|
+
function buildEntryFromMetadata(filename, metadata, defaultDuration) {
|
|
232
|
+
const entry = {
|
|
233
|
+
duration: metadata.durations.get(filename) ?? defaultDuration,
|
|
234
|
+
url: filename
|
|
235
|
+
};
|
|
236
|
+
if (metadata.discontinuities.has(filename)) {
|
|
237
|
+
entry.discontinuity = true;
|
|
238
|
+
}
|
|
239
|
+
const timestamp = metadata.timestamps.get(filename);
|
|
240
|
+
if (timestamp) {
|
|
241
|
+
entry.programDateTime = timestamp;
|
|
242
|
+
}
|
|
243
|
+
if (metadata.cueIns.has(filename)) {
|
|
244
|
+
entry.cueIn = true;
|
|
245
|
+
}
|
|
246
|
+
const cueOut = metadata.cueOuts.get(filename);
|
|
247
|
+
if (cueOut !== undefined) {
|
|
248
|
+
entry.cueOut = cueOut;
|
|
249
|
+
}
|
|
250
|
+
const cueOutCont = metadata.cueOutConts.get(filename);
|
|
251
|
+
if (cueOutCont !== undefined) {
|
|
252
|
+
entry.cueOutCont = cueOutCont;
|
|
253
|
+
}
|
|
254
|
+
return entry;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Prunes stale entries from a SegmentMetadata instance. Removes entries for segments that are no longer in the active segment set (evicted from the sliding window).
|
|
258
|
+
* This prevents unbounded growth over hours of streaming.
|
|
259
|
+
*
|
|
260
|
+
* @param meta - The metadata instance to prune.
|
|
261
|
+
* @param activeSegments - Set of filenames currently in the segment store.
|
|
262
|
+
*/
|
|
263
|
+
function pruneMetadata(meta, activeSegments) {
|
|
264
|
+
for (const key of meta.durations.keys()) {
|
|
265
|
+
if (!activeSegments.has(key)) {
|
|
266
|
+
meta.cueIns.delete(key);
|
|
267
|
+
meta.cueOutConts.delete(key);
|
|
268
|
+
meta.cueOuts.delete(key);
|
|
269
|
+
meta.discontinuities.delete(key);
|
|
270
|
+
meta.durations.delete(key);
|
|
271
|
+
meta.timestamps.delete(key);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
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
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Creates a native HLS proxy that polls a variant manifest, fetches segments, and generates playlists.
|
|
456
|
+
*
|
|
457
|
+
* @param options - Proxy configuration.
|
|
458
|
+
* @returns The native proxy interface.
|
|
459
|
+
*/
|
|
460
|
+
export function createNativeProxy(options) {
|
|
461
|
+
const { channelName, encryption, keyUrl, onError, streamId } = options;
|
|
462
|
+
const hasAudio = options.audioVariantUrl !== null;
|
|
463
|
+
let activeCdpSession = options.cdpSession;
|
|
464
|
+
// Preroll segment index offset. When preroll is ready (prerollSegmentCount > 0), real segments start numbering after the preroll range (e.g., segmentN.ts where
|
|
465
|
+
// N = prerollSegmentCount). This offset is unconditional — it reserves the index space for preroll regardless of whether the deferred preroll timer fires.
|
|
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";
|
|
468
|
+
const prerollSegmentCount = options.prerollSegmentCount ?? 0;
|
|
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
|
+
};
|
|
510
|
+
// Composite playlist discontinuity tracking. This is the composite path's independent source of truth for DISCONTINUITY-SEQUENCE computation. The Set records URLs
|
|
511
|
+
// of entries that have had discontinuity=true in any composite playlist (upstream or the synthetic preroll boundary). It grows monotonically — once a discontinuity
|
|
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
|
|
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.
|
|
514
|
+
// After bootstrap, the Set is self-sufficient. DISCONTINUITY-SEQUENCE = (offset + set.size) - windowDiscontinuities.
|
|
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 };
|
|
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
|
|
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.
|
|
530
|
+
const keysByUrl = new Map();
|
|
531
|
+
if (options.prefetchedKey && keyUrl) {
|
|
532
|
+
keysByUrl.set(keyUrl, options.prefetchedKey);
|
|
533
|
+
}
|
|
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
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Orchestrates the poll cycle: fetches video and audio manifests in parallel, stores segments, and generates playlists after both stores are updated.
|
|
545
|
+
*/
|
|
546
|
+
async function pollManifest() {
|
|
547
|
+
if (lifecycle.stopped) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const pollElapsed = startTimer();
|
|
551
|
+
try {
|
|
552
|
+
const response = await chromeFetch(video.variantUrl, { signal: AbortSignal.timeout(SEGMENT_FETCH_TIMEOUT) });
|
|
553
|
+
if (!response.ok) {
|
|
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) + ")");
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
schedulePoll(nextBackoffDelay());
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
video.consecutiveManifestFailures = 0;
|
|
571
|
+
lifecycle.manifestBackoffMs = MANIFEST_BACKOFF_BASE;
|
|
572
|
+
const body = await response.text();
|
|
573
|
+
LOG.debug("native:proxy", "Manifest poll for %s completed in %sms.", channelName, pollElapsed());
|
|
574
|
+
LOG.debug("native:manifest", "Variant manifest for %s:\n%s", channelName, body);
|
|
575
|
+
// Fetch and store video and audio segments in parallel so neither delays the other, and generate playlists after both stores are updated. This ensures video
|
|
576
|
+
// and audio playlists are published atomically — no window where a client sees a master playlist referencing audio.m3u8 before audio segments exist.
|
|
577
|
+
let hasNewVideoSegments;
|
|
578
|
+
let hasNewAudioSegments = false;
|
|
579
|
+
if (hasAudio) {
|
|
580
|
+
[hasNewVideoSegments, hasNewAudioSegments] = await Promise.all([processManifest(body), pollAudioStream(ctx, audio, fetchTrackedSegment)]);
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
hasNewVideoSegments = await processManifest(body);
|
|
584
|
+
}
|
|
585
|
+
if (hasNewVideoSegments || hasNewAudioSegments) {
|
|
586
|
+
generatePlaylist(video.lastTargetDuration);
|
|
587
|
+
if (hasAudio) {
|
|
588
|
+
generateAudioPlaylist(audio.lastTargetDuration);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// Set firstPollComplete when segments are available. For streams with separate audio, require both video and audio segments before signaling readiness to
|
|
592
|
+
// prevent publishing a master playlist before both variant playlists have content.
|
|
593
|
+
if (!lifecycle.firstPollComplete && (video.segmentIndex > 0) && (!hasAudio || (audio.segmentIndex > 0))) {
|
|
594
|
+
lifecycle.firstPollComplete = true;
|
|
595
|
+
}
|
|
596
|
+
// Signal playlist readiness after the first poll completes. Note: initSegmentReady is signaled immediately when the proxy starts (in hls.ts) since native
|
|
597
|
+
// MPEG-TS has no separate init segment.
|
|
598
|
+
if (lifecycle.firstPollComplete && !lifecycle.readinessSignaled) {
|
|
599
|
+
lifecycle.readinessSignaled = true;
|
|
600
|
+
const stream = getStream(streamId);
|
|
601
|
+
if (stream) {
|
|
602
|
+
stream.hls.signalPlaylistReady();
|
|
603
|
+
}
|
|
604
|
+
LOG.debug("native:proxy", "First poll cycle complete for %s. Segment index: %s.", channelName, video.segmentIndex);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
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;
|
|
615
|
+
onError("manifest poll error: " + String(error));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
schedulePoll(nextBackoffDelay());
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
// Schedule the next poll at roughly half the target segment duration for timely detection of new segments.
|
|
622
|
+
lifecycle.manifestBackoffMs = MANIFEST_BACKOFF_BASE;
|
|
623
|
+
schedulePoll(MANIFEST_BACKOFF_BASE);
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Parses a variant manifest and fetches new segments. Playlist generation is handled by the caller after both video and audio segments are stored.
|
|
627
|
+
*
|
|
628
|
+
* @param body - The variant manifest text content.
|
|
629
|
+
* @returns True if new segments were stored, false if the media sequence hadn't advanced.
|
|
630
|
+
*/
|
|
631
|
+
async function processManifest(body) {
|
|
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);
|
|
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
|
|
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
|
|
646
|
+
// filters out DAI backfill segments from interleaved session windows — segments that are "new" (not in fetchedSequences) but have lower sequence numbers than
|
|
647
|
+
// what we've already served, causing PTS discontinuities and visible playback glitches.
|
|
648
|
+
let newSegments;
|
|
649
|
+
if (video.highWaterSequence === -1) {
|
|
650
|
+
// First poll — no baseline. Take the tail of the manifest to fill the playlist window.
|
|
651
|
+
newSegments = segments.slice(-CONFIG.hls.maxSegments);
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
// Subsequent polls — only fetch segments that advance past the high-water mark.
|
|
655
|
+
newSegments = segments.filter((s) => (s.sequence > video.highWaterSequence) && !video.fetchedSequences.has(s.sequence));
|
|
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
|
|
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
|
|
658
|
+
// high-water mark and re-process the same manifest with the tail-fill strategy.
|
|
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;
|
|
663
|
+
newSegments = segments.slice(-CONFIG.hls.maxSegments);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// Clear the token refresh flag on the first poll that produces segments, whether via normal filtering or after a reset detection.
|
|
667
|
+
if ((newSegments.length > 0) && video.tokenRefreshPending) {
|
|
668
|
+
video.tokenRefreshPending = false;
|
|
669
|
+
}
|
|
670
|
+
if (newSegments.length === 0) {
|
|
671
|
+
// No new segments — the media sequence hasn't advanced.
|
|
672
|
+
if (video.lastMediaSequence === mediaSequence) {
|
|
673
|
+
LOG.debug("native:proxy", "No new segments for %s (sequence still %s).", channelName, mediaSequence);
|
|
674
|
+
}
|
|
675
|
+
video.lastMediaSequence = mediaSequence;
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
LOG.debug("native:proxy", "Fetching %s new segment(s) for %s (sequence %s).", newSegments.length, channelName, mediaSequence);
|
|
679
|
+
video.lastMediaSequence = mediaSequence;
|
|
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.
|
|
681
|
+
let storedAny = false;
|
|
682
|
+
for (const seg of newSegments) {
|
|
683
|
+
if (lifecycle.stopped) {
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
// eslint-disable-next-line no-await-in-loop
|
|
687
|
+
const segmentData = await fetchTrackedSegment(video.segmentTracker, seg.url, seg.sequence, seg.ivHex, seg.keyUrl);
|
|
688
|
+
if (!segmentData) {
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const filename = "segment" + String(video.segmentIndex) + ".ts";
|
|
692
|
+
// Check segment count before store to detect rotation (oldest segment evicted to enforce maxSegments limit).
|
|
693
|
+
const stream = getStream(streamId);
|
|
694
|
+
const countBefore = stream?.hls.segments.size ?? 0;
|
|
695
|
+
storeSegment(streamId, filename, segmentData);
|
|
696
|
+
video.fetchedSequences.add(seg.sequence);
|
|
697
|
+
video.highWaterSequence = Math.max(video.highWaterSequence, seg.sequence);
|
|
698
|
+
storeSegmentMetadata(video.metadata, filename, seg);
|
|
699
|
+
storedAny = true;
|
|
700
|
+
video.lastSegmentSize = segmentData.length;
|
|
701
|
+
video.lastSegmentTime = Date.now();
|
|
702
|
+
video.segmentIndex++;
|
|
703
|
+
stats.totalSegmentsFetched++;
|
|
704
|
+
// Log the first segment fetch latency for timing diagnostics.
|
|
705
|
+
if (stats.totalSegmentsFetched === 1) {
|
|
706
|
+
LOG.debug("timing:native", "First segment fetched for %s (%s bytes).", channelName, segmentData.length);
|
|
707
|
+
}
|
|
708
|
+
// Detect segment rotation — if the count didn't increase, the oldest segment was evicted.
|
|
709
|
+
const countAfter = stream?.hls.segments.size ?? 0;
|
|
710
|
+
if ((countAfter <= countBefore) && (countBefore > 0)) {
|
|
711
|
+
LOG.debug("native:proxy", "Segment rotated for %s (oldest removed, %s segments retained).", channelName, countAfter);
|
|
712
|
+
}
|
|
713
|
+
LOG.debug("native:proxy", "Stored %s (%s bytes, seq %s) for %s.", filename, segmentData.length, seg.sequence, channelName);
|
|
714
|
+
}
|
|
715
|
+
return storedAny;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Fetches and optionally decrypts a segment. This is the shared core used by both video and audio fetch paths. It handles the HTTP fetch, AES-128 decryption with
|
|
719
|
+
* key caching, and IV derivation. Error tracking is the caller's responsibility since video and audio have independent failure thresholds.
|
|
720
|
+
*
|
|
721
|
+
* @param url - The segment URL.
|
|
722
|
+
* @param sequence - The media sequence number (for IV derivation).
|
|
723
|
+
* @param ivHex - Explicit IV hex string from the manifest, or null.
|
|
724
|
+
* @param segKeyUrl - The key URL for this specific segment from the manifest's #EXT-X-KEY tag, or null for clear segments.
|
|
725
|
+
* @returns The segment data (decrypted if necessary), or null on failure.
|
|
726
|
+
*/
|
|
727
|
+
async function fetchAndDecryptSegment(url, sequence, ivHex, segKeyUrl) {
|
|
728
|
+
const response = await chromeFetch(url, { signal: AbortSignal.timeout(SEGMENT_FETCH_TIMEOUT) });
|
|
729
|
+
if (!response.ok) {
|
|
730
|
+
LOG.debug("native:proxy", "Segment fetch failed for %s: HTTP %s.", channelName, response.status);
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
let data = Buffer.from(await response.arrayBuffer());
|
|
734
|
+
// Decrypt if this segment has a key URL. The manifest's #EXT-X-KEY tag is authoritative — DAI streams can switch between clear and AES-128 mid-stream (e.g., ad
|
|
735
|
+
// pods encrypted while main content is clear), so the initial probe classification cannot be relied upon. Segments before the first #EXT-X-KEY tag have
|
|
736
|
+
// segKeyUrl === null (clear).
|
|
737
|
+
if (segKeyUrl) {
|
|
738
|
+
// Look up the key in the per-URL cache, or fetch it if not cached.
|
|
739
|
+
let key = keysByUrl.get(segKeyUrl);
|
|
740
|
+
if (!key) {
|
|
741
|
+
key = await fetchDecryptionKey(segKeyUrl) ?? undefined;
|
|
742
|
+
if (!key) {
|
|
743
|
+
LOG.debug("native:proxy", "Decryption key unavailable for %s (key URL: %s).", channelName, segKeyUrl.slice(0, 80));
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
keysByUrl.set(segKeyUrl, key);
|
|
747
|
+
LOG.debug("native:proxy", "New decryption key cached for %s.", channelName);
|
|
748
|
+
}
|
|
749
|
+
// Determine the IV: use explicit IV from the manifest, or derive from the media sequence number.
|
|
750
|
+
const iv = ivHex ? (parseExplicitIv(ivHex) ?? deriveIvFromSequence(sequence)) : deriveIvFromSequence(sequence);
|
|
751
|
+
LOG.debug("native:decrypt", "IV source for sequence %s: %s.", sequence, ivHex ? "explicit" : "sequence");
|
|
752
|
+
data = decryptSegment(data, key, iv);
|
|
753
|
+
}
|
|
754
|
+
return data;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Fetches a segment with error tracking. Wraps fetchAndDecryptSegment with per-tracker failure counting. Consecutive failures trigger the error threshold which
|
|
758
|
+
* stops the proxy and signals the monitor for recovery. Used for both video and audio segments with independent trackers.
|
|
759
|
+
*
|
|
760
|
+
* @param tracker - Mutable error tracking state with labels for log and error messages.
|
|
761
|
+
* @param url - The segment URL.
|
|
762
|
+
* @param sequence - The media sequence number (for IV derivation).
|
|
763
|
+
* @param ivHex - Explicit IV hex string from the manifest, or null.
|
|
764
|
+
* @param segKeyUrl - The key URL for this specific segment, or null for clear segments.
|
|
765
|
+
* @returns The segment data (decrypted if necessary), or null on failure.
|
|
766
|
+
*/
|
|
767
|
+
async function fetchTrackedSegment(tracker, url, sequence, ivHex, segKeyUrl) {
|
|
768
|
+
try {
|
|
769
|
+
const data = await fetchAndDecryptSegment(url, sequence, ivHex, segKeyUrl);
|
|
770
|
+
if (!data) {
|
|
771
|
+
tracker.consecutiveFailures++;
|
|
772
|
+
stats.totalFetchErrors++;
|
|
773
|
+
if (tracker.consecutiveFailures >= MAX_SEGMENT_FAILURES) {
|
|
774
|
+
lifecycle.errorThresholdReached = true;
|
|
775
|
+
lifecycle.stopped = true;
|
|
776
|
+
onError(tracker.label + " fetch failed " + String(tracker.consecutiveFailures) + " times");
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
tracker.consecutiveFailures = 0;
|
|
781
|
+
return data;
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
tracker.consecutiveFailures++;
|
|
785
|
+
stats.totalFetchErrors++;
|
|
786
|
+
LOG.debug("native:proxy", "%s fetch failed for %s: %s.", tracker.debugLabel, channelName, String(error));
|
|
787
|
+
if (tracker.consecutiveFailures >= MAX_SEGMENT_FAILURES) {
|
|
788
|
+
lifecycle.errorThresholdReached = true;
|
|
789
|
+
lifecycle.stopped = true;
|
|
790
|
+
onError(tracker.label + " fetch error: " + String(error));
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Generates playlists from the stored segments. For streams without separate audio, generates a single variant playlist. When preroll is active (muxed audio only),
|
|
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
|
|
798
|
+
* served during the standalone preroll phase, ensuring smooth MEDIA-SEQUENCE progression. For streams with separate audio, generates a master playlist referencing
|
|
799
|
+
* video.m3u8 and audio.m3u8, plus individual variant playlists for each (no preroll — preroll is muxed and can't be split into separate renditions).
|
|
800
|
+
*
|
|
801
|
+
* @param targetDuration - The #EXT-X-TARGETDURATION value from the provider's manifest.
|
|
802
|
+
*/
|
|
803
|
+
function generatePlaylist(targetDuration) {
|
|
804
|
+
const stream = getStream(streamId);
|
|
805
|
+
if (!stream) {
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const segmentEntries = Array.from(stream.hls.segments.keys());
|
|
809
|
+
if (!hasAudio) {
|
|
810
|
+
if ((prerollSegmentCount > 0) && stream.hls.prerollStartTime && stream.hls.prerollBaseUrl) {
|
|
811
|
+
// Composite playlist with fMP4 preroll entries + MPEG-TS real entries. The prerollStartTime check ensures we only include preroll entries when the deferred
|
|
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)
|
|
813
|
+
// would include unnecessary preroll entries. The compositor handles the sliding window with the maxPrerollInWindow cap.
|
|
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
|
+
}));
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
// No preroll active — standard variant playlist. The segment index may still be offset (starting at prerollSegmentCount) to reserve the index space, but no
|
|
827
|
+
// preroll entries are included.
|
|
828
|
+
updatePlaylist(streamId, buildVariantPlaylist(segmentEntries, video.metadata, "segment", targetDuration));
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
// Separate audio — generate video variant playlist and master playlist. Preroll is not supported for separate audio streams (preroll is muxed).
|
|
833
|
+
updateVideoPlaylist(streamId, buildVariantPlaylist(segmentEntries, video.metadata, "segment", targetDuration));
|
|
834
|
+
// Estimate bandwidth from stored segment sizes and durations. Sum total bytes and total duration across video segments, then convert to bits per second.
|
|
835
|
+
// Falls back to 5 Mbps when no duration data is available (first segment before durations are populated).
|
|
836
|
+
let bandwidth = 5000000;
|
|
837
|
+
let totalBytes = 0;
|
|
838
|
+
let totalDuration = 0;
|
|
839
|
+
for (const filename of segmentEntries) {
|
|
840
|
+
const size = stream.hls.segments.get(filename)?.length ?? 0;
|
|
841
|
+
const duration = video.metadata.durations.get(filename);
|
|
842
|
+
if (duration) {
|
|
843
|
+
totalBytes += size;
|
|
844
|
+
totalDuration += duration;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (totalDuration > 0) {
|
|
848
|
+
bandwidth = Math.round((totalBytes * 8) / totalDuration);
|
|
849
|
+
}
|
|
850
|
+
// Generate the master playlist referencing video.m3u8 and audio.m3u8.
|
|
851
|
+
const masterPlaylist = "#EXTM3U\n" +
|
|
852
|
+
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",NAME=\"English\",DEFAULT=YES,AUTOSELECT=YES,URI=\"audio.m3u8\"\n" +
|
|
853
|
+
"#EXT-X-STREAM-INF:BANDWIDTH=" + String(bandwidth) + ",AUDIO=\"audio\"\n" +
|
|
854
|
+
"video.m3u8\n";
|
|
855
|
+
updatePlaylist(streamId, masterPlaylist);
|
|
856
|
+
}
|
|
857
|
+
LOG.debug("native:proxy", "Playlist generated for %s with %s segment(s), target duration %ss.", channelName, segmentEntries.length, targetDuration);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Generates the audio variant playlist from stored audio segments.
|
|
861
|
+
*
|
|
862
|
+
* @param targetDuration - The #EXT-X-TARGETDURATION value from the audio manifest.
|
|
863
|
+
*/
|
|
864
|
+
function generateAudioPlaylist(targetDuration) {
|
|
865
|
+
const stream = getStream(streamId);
|
|
866
|
+
if (!stream) {
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const audioEntries = Array.from(stream.hls.audioSegments.keys());
|
|
870
|
+
updateAudioPlaylist(streamId, buildVariantPlaylist(audioEntries, audio.metadata, "audio", targetDuration));
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Schedules the next manifest poll after a delay.
|
|
874
|
+
*
|
|
875
|
+
* @param delayMs - Delay in milliseconds before the next poll.
|
|
876
|
+
*/
|
|
877
|
+
function schedulePoll(delayMs) {
|
|
878
|
+
if (lifecycle.stopped) {
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
lifecycle.pollTimer = setTimeout(() => {
|
|
882
|
+
void pollManifest();
|
|
883
|
+
}, delayMs);
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
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,
|
|
891
|
+
getStats: () => ({
|
|
892
|
+
fetchErrors: stats.totalFetchErrors,
|
|
893
|
+
segmentsFetched: stats.totalSegmentsFetched,
|
|
894
|
+
tokenRefreshes: stats.totalTokenRefreshes
|
|
895
|
+
}),
|
|
896
|
+
getTargetDuration: () => video.lastTargetDuration,
|
|
897
|
+
hasErrored: () => lifecycle.errorThresholdReached,
|
|
898
|
+
isStopped: () => lifecycle.stopped,
|
|
899
|
+
setTokenRefreshTimer: (timer) => {
|
|
900
|
+
lifecycle.tokenRefreshTimer = timer;
|
|
901
|
+
},
|
|
902
|
+
start: () => {
|
|
903
|
+
LOG.debug("native:proxy", "Starting native proxy for %s (%s).", channelName, encryption);
|
|
904
|
+
void pollManifest();
|
|
905
|
+
},
|
|
906
|
+
stop: () => {
|
|
907
|
+
lifecycle.stopped = true;
|
|
908
|
+
if (lifecycle.pollTimer) {
|
|
909
|
+
clearTimeout(lifecycle.pollTimer);
|
|
910
|
+
lifecycle.pollTimer = null;
|
|
911
|
+
}
|
|
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
|
|
913
|
+
// potentially closed or reused page.
|
|
914
|
+
if (lifecycle.tokenRefreshTimer) {
|
|
915
|
+
clearTimeout(lifecycle.tokenRefreshTimer);
|
|
916
|
+
lifecycle.tokenRefreshTimer = null;
|
|
917
|
+
}
|
|
918
|
+
// Clean up the CDP session from manifest interception to prevent session leaks.
|
|
919
|
+
removeManifestInterceptor(activeCdpSession);
|
|
920
|
+
LOG.debug("native:proxy", "Stopped native proxy for %s.", channelName);
|
|
921
|
+
},
|
|
922
|
+
updateAudioVariantUrl: (newUrl) => {
|
|
923
|
+
audio.variantUrl = newUrl;
|
|
924
|
+
audio.fetchedSequences.clear();
|
|
925
|
+
audio.tokenRefreshPending = true;
|
|
926
|
+
LOG.debug("native:proxy", "Audio variant URL updated for %s.", channelName);
|
|
927
|
+
},
|
|
928
|
+
updateCdpSession: (session) => {
|
|
929
|
+
// Clean up the old CDP session before replacing it. This prevents session leaks when the session is swapped during token refresh or L2 recovery.
|
|
930
|
+
removeManifestInterceptor(activeCdpSession);
|
|
931
|
+
activeCdpSession = session;
|
|
932
|
+
},
|
|
933
|
+
updateVariantUrl: (newUrl) => {
|
|
934
|
+
video.variantUrl = newUrl;
|
|
935
|
+
stats.totalTokenRefreshes++;
|
|
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."
|
|
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
|
|
938
|
+
// allow the proxy to re-fetch segments the client already consumed, causing PTS discontinuities and visible playback glitches. The tokenRefreshPending flag
|
|
939
|
+
// enables processManifest to detect genuine sequence timeline resets (e.g., Fox Sports) on the first poll after refresh.
|
|
940
|
+
video.fetchedSequences.clear();
|
|
941
|
+
video.lastMediaSequence = -1;
|
|
942
|
+
video.tokenRefreshPending = true;
|
|
943
|
+
LOG.debug("native:proxy", "Variant URL updated for %s. Segment tracking reset.", channelName);
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
//# sourceMappingURL=proxy.js.map
|