googlevideo 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +93 -45
  2. package/dist/protos/generated/misc/common.d.ts +180 -6
  3. package/dist/protos/generated/misc/common.js +282 -19
  4. package/dist/protos/generated/misc/common.js.map +1 -0
  5. package/dist/protos/generated/video_streaming/buffered_range.d.ts +10 -10
  6. package/dist/protos/generated/video_streaming/buffered_range.js +20 -19
  7. package/dist/protos/generated/video_streaming/buffered_range.js.map +1 -0
  8. package/dist/protos/generated/video_streaming/client_abr_state.d.ts +22 -11
  9. package/dist/protos/generated/video_streaming/client_abr_state.js +133 -50
  10. package/dist/protos/generated/video_streaming/client_abr_state.js.map +1 -0
  11. package/dist/protos/generated/video_streaming/crypto_params.d.ts +2 -7
  12. package/dist/protos/generated/video_streaming/crypto_params.js +1 -7
  13. package/dist/protos/generated/video_streaming/crypto_params.js.map +1 -0
  14. package/dist/protos/generated/video_streaming/format_initialization_metadata.d.ts +6 -6
  15. package/dist/protos/generated/video_streaming/format_initialization_metadata.js +32 -21
  16. package/dist/protos/generated/video_streaming/format_initialization_metadata.js.map +1 -0
  17. package/dist/protos/generated/video_streaming/format_selection_config.d.ts +12 -0
  18. package/dist/protos/generated/video_streaming/format_selection_config.js +68 -0
  19. package/dist/protos/generated/video_streaming/format_selection_config.js.map +1 -0
  20. package/dist/protos/generated/video_streaming/{encrypted_player_request.d.ts → innertube_request.d.ts} +11 -6
  21. package/dist/protos/generated/video_streaming/{encrypted_player_request.js → innertube_request.js} +64 -20
  22. package/dist/protos/generated/video_streaming/innertube_request.js.map +1 -0
  23. package/dist/protos/generated/video_streaming/live_metadata.d.ts +8 -5
  24. package/dist/protos/generated/video_streaming/live_metadata.js +57 -26
  25. package/dist/protos/generated/video_streaming/live_metadata.js.map +1 -0
  26. package/dist/protos/generated/video_streaming/media_capabilities.js +1 -0
  27. package/dist/protos/generated/video_streaming/media_capabilities.js.map +1 -0
  28. package/dist/protos/generated/video_streaming/media_header.d.ts +5 -10
  29. package/dist/protos/generated/video_streaming/media_header.js +27 -23
  30. package/dist/protos/generated/video_streaming/media_header.js.map +1 -0
  31. package/dist/protos/generated/video_streaming/next_request_policy.d.ts +3 -0
  32. package/dist/protos/generated/video_streaming/next_request_policy.js +31 -0
  33. package/dist/protos/generated/video_streaming/next_request_policy.js.map +1 -0
  34. package/dist/protos/generated/video_streaming/onesie_header.d.ts +7 -7
  35. package/dist/protos/generated/video_streaming/onesie_header.js +15 -14
  36. package/dist/protos/generated/video_streaming/onesie_header.js.map +1 -0
  37. package/dist/protos/generated/video_streaming/onesie_header_type.d.ts +9 -23
  38. package/dist/protos/generated/video_streaming/onesie_header_type.js +16 -30
  39. package/dist/protos/generated/video_streaming/onesie_header_type.js.map +1 -0
  40. package/dist/protos/generated/video_streaming/{onesie_player_request.d.ts → onesie_innertube_request.d.ts} +2 -2
  41. package/dist/protos/generated/video_streaming/{onesie_player_request.js → onesie_innertube_request.js} +5 -4
  42. package/dist/protos/generated/video_streaming/onesie_innertube_request.js.map +1 -0
  43. package/dist/protos/generated/video_streaming/onesie_innertube_response.d.ts +15 -0
  44. package/dist/protos/generated/video_streaming/{onesie_player_response.js → onesie_innertube_response.js} +5 -22
  45. package/dist/protos/generated/video_streaming/onesie_innertube_response.js.map +1 -0
  46. package/dist/protos/generated/video_streaming/onesie_proxy_status.d.ts +18 -0
  47. package/dist/protos/generated/video_streaming/onesie_proxy_status.js +25 -0
  48. package/dist/protos/generated/video_streaming/onesie_proxy_status.js.map +1 -0
  49. package/dist/protos/generated/video_streaming/onesie_request.d.ts +6 -3
  50. package/dist/protos/generated/video_streaming/onesie_request.js +17 -5
  51. package/dist/protos/generated/video_streaming/onesie_request.js.map +1 -0
  52. package/dist/protos/generated/video_streaming/playback_cookie.d.ts +2 -2
  53. package/dist/protos/generated/video_streaming/playback_cookie.js +5 -4
  54. package/dist/protos/generated/video_streaming/playback_cookie.js.map +1 -0
  55. package/dist/protos/generated/video_streaming/playback_start_policy.js +1 -0
  56. package/dist/protos/generated/video_streaming/playback_start_policy.js.map +1 -0
  57. package/dist/protos/generated/video_streaming/reload_player_response.d.ts +14 -0
  58. package/dist/protos/generated/video_streaming/reload_player_response.js +73 -0
  59. package/dist/protos/generated/video_streaming/reload_player_response.js.map +1 -0
  60. package/dist/protos/generated/video_streaming/request_cancellation_policy.js +1 -0
  61. package/dist/protos/generated/video_streaming/request_cancellation_policy.js.map +1 -0
  62. package/dist/protos/generated/video_streaming/request_identifier.d.ts +10 -0
  63. package/dist/protos/generated/video_streaming/request_identifier.js +41 -0
  64. package/dist/protos/generated/video_streaming/request_identifier.js.map +1 -0
  65. package/dist/protos/generated/video_streaming/sabr_context_sending_policy.d.ts +12 -0
  66. package/dist/protos/generated/video_streaming/sabr_context_sending_policy.js +86 -0
  67. package/dist/protos/generated/video_streaming/sabr_context_sending_policy.js.map +1 -0
  68. package/dist/protos/generated/video_streaming/sabr_context_update.d.ts +48 -0
  69. package/dist/protos/generated/video_streaming/sabr_context_update.js +242 -0
  70. package/dist/protos/generated/video_streaming/sabr_context_update.js.map +1 -0
  71. package/dist/protos/generated/video_streaming/sabr_error.js +1 -0
  72. package/dist/protos/generated/video_streaming/sabr_error.js.map +1 -0
  73. package/dist/protos/generated/video_streaming/sabr_redirect.js +1 -0
  74. package/dist/protos/generated/video_streaming/sabr_redirect.js.map +1 -0
  75. package/dist/protos/generated/video_streaming/snackbar_message.d.ts +10 -0
  76. package/dist/protos/generated/video_streaming/snackbar_message.js +41 -0
  77. package/dist/protos/generated/video_streaming/snackbar_message.js.map +1 -0
  78. package/dist/protos/generated/video_streaming/stream_protection_status.d.ts +1 -1
  79. package/dist/protos/generated/video_streaming/stream_protection_status.js +5 -4
  80. package/dist/protos/generated/video_streaming/stream_protection_status.js.map +1 -0
  81. package/dist/protos/generated/video_streaming/streamer_context.d.ts +11 -11
  82. package/dist/protos/generated/video_streaming/streamer_context.js +32 -32
  83. package/dist/protos/generated/video_streaming/streamer_context.js.map +1 -0
  84. package/dist/protos/generated/video_streaming/time_range.d.ts +2 -2
  85. package/dist/protos/generated/video_streaming/time_range.js +8 -7
  86. package/dist/protos/generated/video_streaming/time_range.js.map +1 -0
  87. package/dist/protos/generated/video_streaming/ump_part_id.d.ts +58 -0
  88. package/dist/protos/generated/video_streaming/ump_part_id.js +89 -0
  89. package/dist/protos/generated/video_streaming/ump_part_id.js.map +1 -0
  90. package/dist/protos/generated/video_streaming/video_playback_abr_request.d.ts +20 -21
  91. package/dist/protos/generated/video_streaming/video_playback_abr_request.js +48 -86
  92. package/dist/protos/generated/video_streaming/video_playback_abr_request.js.map +1 -0
  93. package/dist/src/core/{ChunkedDataBuffer.d.ts → CompositeBuffer.d.ts} +8 -8
  94. package/dist/src/core/{ChunkedDataBuffer.js → CompositeBuffer.js} +32 -27
  95. package/dist/src/core/CompositeBuffer.js.map +1 -0
  96. package/dist/src/core/SabrStream.d.ts +356 -0
  97. package/dist/src/core/SabrStream.js +958 -0
  98. package/dist/src/core/SabrStream.js.map +1 -0
  99. package/dist/src/core/SabrStreamingAdapter.d.ts +158 -0
  100. package/dist/src/core/SabrStreamingAdapter.js +496 -0
  101. package/dist/src/core/SabrStreamingAdapter.js.map +1 -0
  102. package/dist/src/core/SabrUmpProcessor.d.ts +53 -0
  103. package/dist/src/core/SabrUmpProcessor.js +234 -0
  104. package/dist/src/core/SabrUmpProcessor.js.map +1 -0
  105. package/dist/src/core/{UMP.d.ts → UmpReader.d.ts} +6 -10
  106. package/dist/src/core/{UMP.js → UmpReader.js} +33 -36
  107. package/dist/src/core/UmpReader.js.map +1 -0
  108. package/dist/src/core/UmpWriter.d.ts +16 -0
  109. package/dist/src/core/UmpWriter.js +56 -0
  110. package/dist/src/core/UmpWriter.js.map +1 -0
  111. package/dist/src/exports/protos.d.ts +1 -0
  112. package/dist/src/exports/protos.js +2 -0
  113. package/dist/src/exports/protos.js.map +1 -0
  114. package/dist/src/exports/sabr-stream.d.ts +2 -0
  115. package/dist/src/exports/sabr-stream.js +2 -0
  116. package/dist/src/exports/sabr-stream.js.map +1 -0
  117. package/dist/src/exports/sabr-streaming-adapter.d.ts +3 -0
  118. package/dist/src/exports/sabr-streaming-adapter.js +3 -0
  119. package/dist/src/exports/sabr-streaming-adapter.js.map +1 -0
  120. package/dist/src/exports/ump.d.ts +3 -0
  121. package/dist/src/exports/ump.js +4 -0
  122. package/dist/src/exports/ump.js.map +1 -0
  123. package/dist/src/exports/utils.d.ts +1 -0
  124. package/dist/src/exports/utils.js +2 -0
  125. package/dist/src/exports/utils.js.map +1 -0
  126. package/dist/src/types/sabrStreamTypes.d.ts +102 -0
  127. package/dist/src/types/sabrStreamTypes.js +2 -0
  128. package/dist/src/types/sabrStreamTypes.js.map +1 -0
  129. package/dist/src/types/sabrStreamingAdapterTypes.d.ts +93 -0
  130. package/dist/src/types/sabrStreamingAdapterTypes.js +2 -0
  131. package/dist/src/types/sabrStreamingAdapterTypes.js.map +1 -0
  132. package/dist/src/types/shared.d.ts +64 -0
  133. package/dist/src/types/shared.js +2 -0
  134. package/dist/src/types/shared.js.map +1 -0
  135. package/dist/src/utils/CacheManager.d.ts +32 -0
  136. package/dist/src/utils/CacheManager.js +131 -0
  137. package/dist/src/utils/CacheManager.js.map +1 -0
  138. package/dist/src/utils/EventEmitterLike.d.ts +5 -0
  139. package/dist/src/utils/EventEmitterLike.js +51 -10
  140. package/dist/src/utils/EventEmitterLike.js.map +1 -0
  141. package/dist/src/utils/Logger.d.ts +30 -0
  142. package/dist/src/utils/Logger.js +80 -0
  143. package/dist/src/utils/Logger.js.map +1 -0
  144. package/dist/src/utils/Protos.d.ts +20 -13
  145. package/dist/src/utils/Protos.js +21 -13
  146. package/dist/src/utils/Protos.js.map +1 -0
  147. package/dist/src/utils/RequestMetadataManager.d.ts +15 -0
  148. package/dist/src/utils/RequestMetadataManager.js +48 -0
  149. package/dist/src/utils/RequestMetadataManager.js.map +1 -0
  150. package/dist/src/utils/formatKeyUtils.d.ts +45 -0
  151. package/dist/src/utils/formatKeyUtils.js +77 -0
  152. package/dist/src/utils/formatKeyUtils.js.map +1 -0
  153. package/dist/src/utils/index.d.ts +5 -3
  154. package/dist/src/utils/index.js +6 -2
  155. package/dist/src/utils/index.js.map +1 -0
  156. package/dist/src/utils/sabrStreamUtils.d.ts +28 -0
  157. package/dist/src/utils/sabrStreamUtils.js +76 -0
  158. package/dist/src/utils/sabrStreamUtils.js.map +1 -0
  159. package/dist/src/utils/shared.d.ts +43 -0
  160. package/dist/src/utils/shared.js +108 -0
  161. package/dist/src/utils/shared.js.map +1 -0
  162. package/package.json +40 -12
  163. package/dist/protos/generated/video_streaming/onesie_player_response.d.ts +0 -31
  164. package/dist/protos/generated/video_streaming/proxy_status.d.ts +0 -14
  165. package/dist/protos/generated/video_streaming/proxy_status.js +0 -21
  166. package/dist/src/core/ServerAbrStream.d.ts +0 -39
  167. package/dist/src/core/ServerAbrStream.js +0 -259
  168. package/dist/src/core/index.d.ts +0 -3
  169. package/dist/src/core/index.js +0 -3
  170. package/dist/src/index.d.ts +0 -4
  171. package/dist/src/index.js +0 -4
  172. package/dist/src/utils/helpers.d.ts +0 -63
  173. package/dist/src/utils/helpers.js +0 -101
  174. package/dist/src/utils/types.d.ts +0 -65
  175. package/dist/src/utils/types.js +0 -1
@@ -0,0 +1,958 @@
1
+ import { FormatInitializationMetadata, MediaHeader, NextRequestPolicy, PlaybackCookie, ReloadPlaybackContext, SabrContextSendingPolicy, SabrContextUpdate, SabrContextWritePolicy, SabrError, SabrRedirect, StreamProtectionStatus, VideoPlaybackAbrRequest, UMPPartId } from '../utils/Protos.js';
2
+ import { MAX_INT32_VALUE, EnabledTrackTypes, base64ToU8, EventEmitterLike, Logger, wait } from '../utils/index.js';
3
+ import * as FormatKeyUtils from '../utils/formatKeyUtils.js';
4
+ import { chooseFormat, getMediaType, getTotalDownloadedDuration } from '../utils/sabrStreamUtils.js';
5
+ import { CompositeBuffer } from './CompositeBuffer.js';
6
+ import { UmpReader } from './UmpReader.js';
7
+ const TAG = 'SabrStream';
8
+ const DEFAULT_MAX_RETRIES = 10;
9
+ const MAX_BACKOFF_MS = 8000;
10
+ const BACKOFF_MULTIPLIER = 500;
11
+ const DEFAULT_STALL_DETECTION_MS = 30000;
12
+ const MAX_STALLS = 5;
13
+ /**
14
+ * Manages the download and processing of YouTube's Server-Adaptive Bitrate (SABR) streams.
15
+ *
16
+ * This class handles the entire lifecycle of a SABR stream:
17
+ * - Selecting appropriate video and audio formats.
18
+ * - Making network requests to fetch media segments.
19
+ * - Processing UMP parts in real-time.
20
+ * - Handling server-side directives like redirects, context updates, and backoff policies.
21
+ * - Emitting events for key stream updates, such as format initialization and errors.
22
+ * - Providing separate `ReadableStream` instances for video and audio data.
23
+ */
24
+ export class SabrStream extends EventEmitterLike {
25
+ on(event, listener) {
26
+ super.on(event, listener);
27
+ }
28
+ once(event, listener) {
29
+ super.once(event, listener);
30
+ }
31
+ constructor(config = {}) {
32
+ super();
33
+ this.logger = Logger.getInstance();
34
+ this.formatIds = [];
35
+ this.umpPartHandlers = new Map([
36
+ [UMPPartId.FORMAT_INITIALIZATION_METADATA, this.handleFormatInitializationMetadata.bind(this)],
37
+ [UMPPartId.NEXT_REQUEST_POLICY, this.handleNextRequestPolicy.bind(this)],
38
+ [UMPPartId.SABR_ERROR, this.handleSabrError.bind(this)],
39
+ [UMPPartId.SABR_REDIRECT, this.handleSabrRedirect.bind(this)],
40
+ [UMPPartId.SABR_CONTEXT_UPDATE, this.handleSabrContextUpdate.bind(this)],
41
+ [UMPPartId.SABR_CONTEXT_SENDING_POLICY, this.handleSabrContextSendingPolicy.bind(this)],
42
+ [UMPPartId.STREAM_PROTECTION_STATUS, this.handleStreamProtectionStatus.bind(this)],
43
+ [UMPPartId.RELOAD_PLAYER_RESPONSE, this.handleReloadPlayerResponse.bind(this)],
44
+ [UMPPartId.MEDIA_HEADER, this.handleMediaHeader.bind(this)],
45
+ [UMPPartId.MEDIA, this.handleMedia.bind(this)],
46
+ [UMPPartId.MEDIA_END, this.handleMediaEnd.bind(this)]
47
+ ]);
48
+ this.sabrContexts = new Map();
49
+ this.activeSabrContextTypes = new Set();
50
+ this.initializedFormatsMap = new Map();
51
+ this.partialSegmentQueue = new Map();
52
+ this.requestNumber = 0;
53
+ this.durationMs = Infinity;
54
+ this.mediaHeadersProcessed = false;
55
+ this._errored = false;
56
+ this._aborted = false;
57
+ this.progressTracker = {
58
+ lastProgressTime: Date.now(),
59
+ lastDownloadedDuration: 0,
60
+ stallCount: 0
61
+ };
62
+ this.fetchFunction = config?.fetch || fetch;
63
+ this.serverAbrStreamingUrl = config.serverAbrStreamingUrl;
64
+ this.videoPlaybackUstreamerConfig = config.videoPlaybackUstreamerConfig;
65
+ this.clientInfo = config.clientInfo;
66
+ this.poToken = config.poToken;
67
+ this.durationMs = config.durationMs || Infinity;
68
+ this.formatIds = config.formats || [];
69
+ this.videoStream = new ReadableStream({
70
+ start: (controller) => {
71
+ this.videoController = controller;
72
+ }
73
+ });
74
+ this.audioStream = new ReadableStream({
75
+ start: (controller) => {
76
+ this.audioController = controller;
77
+ }
78
+ });
79
+ }
80
+ /**
81
+ * Sets Proof of Origin (PO) token.
82
+ * @param poToken - The base64-encoded token string.
83
+ */
84
+ setPoToken(poToken) {
85
+ this.poToken = poToken;
86
+ }
87
+ /**
88
+ * Sets the available server ABR formats.
89
+ * @param formats - An array of available SabrFormat objects.
90
+ */
91
+ setServerAbrFormats(formats) {
92
+ this.formatIds.push(...formats);
93
+ }
94
+ /**
95
+ * Sets the total duration of the stream in milliseconds.
96
+ * This is optional as duration is often determined automatically from format metadata.
97
+ * @param durationMs - The duration in milliseconds.
98
+ */
99
+ setDurationMs(durationMs) {
100
+ this.durationMs = durationMs;
101
+ }
102
+ /**
103
+ * Sets the server ABR streaming URL for media requests.
104
+ * @param url - The streaming URL.
105
+ */
106
+ setStreamingURL(url) {
107
+ this.serverAbrStreamingUrl = url;
108
+ }
109
+ /**
110
+ * Sets the Ustreamer configuration string.
111
+ * @param config - The Ustreamer configuration.
112
+ */
113
+ setUstreamerConfig(config) {
114
+ this.videoPlaybackUstreamerConfig = config;
115
+ }
116
+ /**
117
+ * Sets the client information used in SABR requests.
118
+ * @param clientInfo - The client information object.
119
+ */
120
+ setClientInfo(clientInfo) {
121
+ this.clientInfo = clientInfo;
122
+ }
123
+ /**
124
+ * Aborts the download process, closing all streams and cleaning up resources.
125
+ * Emits an 'abort' event.
126
+ */
127
+ abort() {
128
+ this.logger.debug(TAG, 'Aborting download process');
129
+ this._aborted = true;
130
+ this.abortController?.abort();
131
+ this.videoController?.error(new Error('Download aborted.'));
132
+ this.audioController?.error(new Error('Download aborted.'));
133
+ this.resetState();
134
+ this.emit('abort');
135
+ }
136
+ //#region --- Stream Initialization and Lifecycle Control ---
137
+ /**
138
+ * Returns a serializable state object that can be used to restore the stream later.
139
+ * @throws {Error} If the main format is not initialized.
140
+ * @returns The current state of the stream.
141
+ */
142
+ getState() {
143
+ if (!this.mainFormat)
144
+ throw new Error('Main format is not initialized, cannot get state.');
145
+ const playerTimeMs = getTotalDownloadedDuration(this.mainFormat);
146
+ const initializedFormats = [];
147
+ for (const [formatKey, format] of this.initializedFormatsMap.entries()) {
148
+ initializedFormats.push({
149
+ formatKey,
150
+ formatInitializationMetadata: format.formatInitializationMetadata,
151
+ downloadedSegments: Array.from(format.downloadedSegments.entries()),
152
+ lastMediaHeaders: format.lastMediaHeaders
153
+ });
154
+ }
155
+ return {
156
+ durationMs: this.durationMs,
157
+ requestNumber: this.requestNumber,
158
+ activeSabrContexts: Array.from(this.activeSabrContextTypes),
159
+ sabrContextUpdates: Array.from(this.sabrContexts.entries()),
160
+ formatToDiscard: this.formatToDiscard,
161
+ cachedBufferedRanges: this.cachedBufferedRanges || [],
162
+ nextRequestPolicy: this.nextRequestPolicy,
163
+ initializedFormats,
164
+ playerTimeMs
165
+ };
166
+ }
167
+ /**
168
+ * Initiates the streaming process for the selected formats.
169
+ * @param options - Playback options, including format preferences and initial state.
170
+ * @throws {Error} If no suitable formats are found or streaming fails.
171
+ * @returns A promise that resolves with the video/audio streams and selected formats.
172
+ */
173
+ async start(options) {
174
+ const { videoFormat, audioFormat } = this.selectFormats(options);
175
+ this.setupStreamingProcess(videoFormat, audioFormat, options).then();
176
+ return {
177
+ videoStream: this.videoStream,
178
+ audioStream: this.audioStream,
179
+ selectedFormats: { videoFormat, audioFormat }
180
+ };
181
+ }
182
+ /**
183
+ * Sets up and manages the main streaming loop.
184
+ * @param videoFormat - The selected video format.
185
+ * @param audioFormat - The selected audio format.
186
+ * @param options - Playback options.
187
+ * @private
188
+ */
189
+ async setupStreamingProcess(videoFormat, audioFormat, options) {
190
+ try {
191
+ this._errored = false;
192
+ this._aborted = false;
193
+ let playerTimeMs = 0;
194
+ if (options.state && this.restoreState(videoFormat, audioFormat, options.state)) {
195
+ playerTimeMs = options.state.playerTimeMs || 0;
196
+ }
197
+ const maxRetries = options.maxRetries !== undefined ? options.maxRetries : DEFAULT_MAX_RETRIES;
198
+ const enabledTrackTypesBitfield = options.enabledTrackTypes ?? EnabledTrackTypes.VIDEO_AND_AUDIO;
199
+ const abrState = {
200
+ playerTimeMs,
201
+ audioTrackId: audioFormat.audioTrackId,
202
+ playbackRate: 1,
203
+ stickyResolution: videoFormat.height || 360,
204
+ drcEnabled: audioFormat.isDrc,
205
+ clientViewportIsFlexible: false,
206
+ visibility: 1,
207
+ enabledTrackTypesBitfield
208
+ };
209
+ // NOTE: 0 - video & audio, 1 - audio only, 2 - video only
210
+ if (abrState.enabledTrackTypesBitfield === 1 || abrState.enabledTrackTypesBitfield === 2) {
211
+ this.formatToDiscard = abrState.enabledTrackTypesBitfield === 1 ?
212
+ FormatKeyUtils.fromFormat(videoFormat) :
213
+ FormatKeyUtils.fromFormat(audioFormat);
214
+ }
215
+ while (abrState.playerTimeMs < this.durationMs) {
216
+ if (this._aborted) {
217
+ this.logger.debug(TAG, 'Download process aborted, exiting streaming loop.');
218
+ break;
219
+ }
220
+ this.logger.debug(TAG, `Starting new segment fetch at playback position: ${abrState.playerTimeMs}ms`);
221
+ this.mainFormat = abrState.enabledTrackTypesBitfield === 1 ?
222
+ this.initializedFormatsMap.get(FormatKeyUtils.fromFormat(audioFormat) || '') :
223
+ this.initializedFormatsMap.get(FormatKeyUtils.fromFormat(videoFormat) || '');
224
+ if (this.mainFormat)
225
+ this.validateAndCorrectDuration(this.mainFormat.formatInitializationMetadata);
226
+ abrState.playerTimeMs = this.mainFormat ? getTotalDownloadedDuration(this.mainFormat) : 0;
227
+ this.checkForStall({
228
+ playerTimeMs: abrState.playerTimeMs,
229
+ stallDetectionMs: options.stallDetectionMs
230
+ });
231
+ const success = await this.executeWithRetry(() => this.fetchAndProcessSegments(abrState, audioFormat, videoFormat), maxRetries);
232
+ if (!success)
233
+ break;
234
+ }
235
+ }
236
+ catch (error) {
237
+ if (!this._aborted) {
238
+ this.errorHandler(error, true);
239
+ }
240
+ }
241
+ finally {
242
+ if (!this._aborted) {
243
+ this.validateDownloadedSegments();
244
+ if (!this._errored) {
245
+ this.videoController?.close();
246
+ this.audioController?.close();
247
+ }
248
+ this.resetState();
249
+ this.emit('finish');
250
+ }
251
+ }
252
+ }
253
+ /**
254
+ * Restores the stream state from a previously saved state object.
255
+ * @param videoFormat - The selected video format.
256
+ * @param audioFormat - The selected audio format.
257
+ * @param state - The saved state object.
258
+ * @returns `true` if the state was restored successfully, `false` otherwise.
259
+ * @private
260
+ */
261
+ restoreState(videoFormat, audioFormat, state) {
262
+ this.resetState();
263
+ if (!state || typeof state !== 'object' || !state.initializedFormats || !Array.isArray(state.initializedFormats) || !state.durationMs || !state.playerTimeMs) {
264
+ this.logger.warn(TAG, 'Invalid or corrupt state object provided. Starting fresh.');
265
+ return false;
266
+ }
267
+ const expectedVideoFormatKey = FormatKeyUtils.fromFormat(videoFormat) || '';
268
+ const expectedAudioFormatKey = FormatKeyUtils.fromFormat(audioFormat) || '';
269
+ for (const format of state.initializedFormats) {
270
+ const { formatKey, formatInitializationMetadata, downloadedSegments, lastMediaHeaders } = format;
271
+ if (formatKey !== expectedVideoFormatKey && formatKey !== expectedAudioFormatKey) {
272
+ this.logger.warn(TAG, `State contains an unexpected format key "${formatKey}". It will be ignored.`);
273
+ continue;
274
+ }
275
+ this.initializedFormatsMap.set(formatKey, {
276
+ formatInitializationMetadata,
277
+ downloadedSegments: new Map(downloadedSegments),
278
+ lastMediaHeaders: lastMediaHeaders || []
279
+ });
280
+ }
281
+ if (!this.initializedFormatsMap.has(expectedVideoFormatKey) || !this.initializedFormatsMap.has(expectedAudioFormatKey)) {
282
+ this.logger.warn(TAG, 'State is missing required format data for the selected video/audio formats. Starting fresh.');
283
+ this.resetState();
284
+ return false;
285
+ }
286
+ this.durationMs = state.durationMs;
287
+ this.requestNumber = state.requestNumber || 0;
288
+ this.activeSabrContextTypes = new Set(state.activeSabrContexts || []);
289
+ this.sabrContexts = new Map(state.sabrContextUpdates || []);
290
+ this.formatToDiscard = state.formatToDiscard;
291
+ this.cachedBufferedRanges = state.cachedBufferedRanges || [];
292
+ this.nextRequestPolicy = state.nextRequestPolicy;
293
+ return true;
294
+ }
295
+ /**
296
+ * Checks if the download has stalled by tracking progress over time.
297
+ * @param options - Configuration for stall detection.
298
+ * @returns `true` if a stall was detected but is within the retry limit, `false` otherwise.
299
+ * @throws {Error} If the maximum number of consecutive stalls is reached.
300
+ * @private
301
+ */
302
+ checkForStall(options) {
303
+ const currentTime = Date.now();
304
+ const currentProgress = options.playerTimeMs;
305
+ const stallThreshold = options.stallDetectionMs || DEFAULT_STALL_DETECTION_MS;
306
+ if (currentProgress > this.progressTracker.lastDownloadedDuration) {
307
+ this.progressTracker.lastProgressTime = currentTime;
308
+ this.progressTracker.lastDownloadedDuration = currentProgress;
309
+ this.progressTracker.stallCount = 0;
310
+ return false;
311
+ }
312
+ else if (currentTime - this.progressTracker.lastProgressTime > stallThreshold) {
313
+ this.progressTracker.stallCount++;
314
+ this.logger.warn(TAG, `Stream stalled for ${stallThreshold}ms (stall #${this.progressTracker.stallCount})`);
315
+ if (this.progressTracker.stallCount >= MAX_STALLS) {
316
+ throw new Error(`Stream stalled ${MAX_STALLS} times, aborting`);
317
+ }
318
+ this.progressTracker.lastProgressTime = currentTime;
319
+ return true;
320
+ }
321
+ return false;
322
+ }
323
+ /**
324
+ * Selects the best video and audio formats based on provided options.
325
+ * @param options - Format selection options and quality preferences.
326
+ * @throws {Error} If no suitable formats are found or the duration is invalid.
327
+ * @returns The selected video and audio formats.
328
+ * @private
329
+ */
330
+ selectFormats(options) {
331
+ const videoFormat = chooseFormat(this.formatIds, options.videoFormat, {
332
+ quality: options.videoQuality,
333
+ preferWebM: options.preferWebM,
334
+ preferH264: options.preferH264,
335
+ preferMP4: options.preferMP4,
336
+ isAudio: false
337
+ });
338
+ const audioFormat = chooseFormat(this.formatIds, options.audioFormat, {
339
+ quality: options.audioQuality,
340
+ language: options.audioLanguage,
341
+ preferOpus: options.preferOpus,
342
+ preferMP4: options.preferMP4,
343
+ preferWebM: options.preferWebM,
344
+ isAudio: true
345
+ });
346
+ if (this.durationMs < 0) {
347
+ throw new Error('Invalid duration');
348
+ }
349
+ if (!videoFormat || !audioFormat) {
350
+ throw new Error('No suitable formats found for download');
351
+ }
352
+ return { videoFormat, audioFormat };
353
+ }
354
+ //#endregion
355
+ //#region --- Segment Fetching and Network Communication ---
356
+ /**
357
+ * Fetches and processes media segments from the server for the current ABR state.
358
+ * @param abrState - The current client adaptive bitrate state.
359
+ * @param selectedAudioFormat - The selected audio format.
360
+ * @param selectedVideoFormat - The selected video format.
361
+ * @throws {Error} If the server returns an error or no valid data.
362
+ * @private
363
+ */
364
+ async fetchAndProcessSegments(abrState, selectedAudioFormat, selectedVideoFormat) {
365
+ const initializedVideoFormat = this.initializedFormatsMap.get(FormatKeyUtils.fromFormat(selectedVideoFormat) || '');
366
+ const initializedAudioFormat = this.initializedFormatsMap.get(FormatKeyUtils.fromFormat(selectedAudioFormat) || '');
367
+ // Cache buffered ranges in case the request fails, allowing retries to use the same values.
368
+ if (!this.cachedBufferedRanges?.length) {
369
+ this.cachedBufferedRanges = this.buildBufferedRanges(initializedVideoFormat, initializedAudioFormat);
370
+ }
371
+ const requestBody = this.buildRequestBody(abrState, selectedAudioFormat, selectedVideoFormat);
372
+ this.mediaHeadersProcessed = false;
373
+ const response = await this.makeStreamingRequest(requestBody);
374
+ const processedParts = await this.processStreamingResponse(response);
375
+ if (!processedParts.length) {
376
+ throw new Error('No valid parts received from server.');
377
+ }
378
+ else if ((this.streamProtectionStatus?.status || 0) >= 2 && !processedParts.includes(UMPPartId.MEDIA)) {
379
+ throw new Error('No media parts or protocol updates received from server.');
380
+ }
381
+ if (processedParts.includes(UMPPartId.MEDIA_HEADER) &&
382
+ (initializedVideoFormat?.lastMediaHeaders?.length && initializedAudioFormat?.lastMediaHeaders?.length) ||
383
+ (abrState.enabledTrackTypesBitfield !== 0 && this.mainFormat?.lastMediaHeaders?.length)) {
384
+ this.mediaHeadersProcessed = true;
385
+ }
386
+ }
387
+ /**
388
+ * Constructs an array of `BufferedRange` objects from initialized formats.
389
+ * @param initializedVideoFormat - The initialized video format, if available.
390
+ * @param initializedAudioFormat - The initialized audio format, if available.
391
+ * @returns An array of `BufferedRange` objects.
392
+ * @private
393
+ */
394
+ buildBufferedRanges(initializedVideoFormat, initializedAudioFormat) {
395
+ const bufferedRanges = [];
396
+ const formats = [initializedVideoFormat, initializedAudioFormat];
397
+ for (const initializedFormat of formats) {
398
+ if (!initializedFormat?.lastMediaHeaders.length) {
399
+ continue;
400
+ }
401
+ if (
402
+ // Skip formats marked for discarding; a dummy range will be created for them later.
403
+ FormatKeyUtils.fromFormatInitializationMetadata(initializedFormat.formatInitializationMetadata) === this.formatToDiscard) {
404
+ continue;
405
+ }
406
+ const mediaHeaders = initializedFormat.lastMediaHeaders;
407
+ const durationMs = mediaHeaders.reduce((sum, header) => sum + (header.durationMs || 0), 0);
408
+ bufferedRanges.push({
409
+ durationMs,
410
+ formatId: initializedFormat.formatInitializationMetadata.formatId,
411
+ startTimeMs: mediaHeaders[0].startMs || 0,
412
+ startSegmentIndex: mediaHeaders[0].sequenceNumber || 1,
413
+ endSegmentIndex: mediaHeaders[mediaHeaders.length - 1].sequenceNumber || 1,
414
+ timeRange: {
415
+ durationTicks: durationMs,
416
+ startTicks: mediaHeaders[0].startMs,
417
+ timescale: mediaHeaders[0].timeRange?.timescale
418
+ }
419
+ });
420
+ initializedFormat.lastMediaHeaders = [];
421
+ }
422
+ return bufferedRanges;
423
+ }
424
+ /**
425
+ * Builds the protobuf request body for a `VideoPlaybackAbrRequest`.
426
+ * @param abrState - The current client adaptive bitrate state.
427
+ * @param selectedAudioFormat - The selected audio format.
428
+ * @param selectedVideoFormat - The selected video format.
429
+ * @returns The encoded request body as a `Uint8Array`.
430
+ * @throws {Error} If required configuration (ustreamer config, client info) is missing.
431
+ * @private
432
+ */
433
+ buildRequestBody(abrState, selectedAudioFormat, selectedVideoFormat) {
434
+ if (!this.videoPlaybackUstreamerConfig)
435
+ throw new Error('Video playback ustreamer config must be set before starting.');
436
+ if (!this.clientInfo)
437
+ throw new Error('Client info must be set before starting.');
438
+ const bufferedRanges = this.cachedBufferedRanges || [];
439
+ const { sabrContexts, unsentSabrContexts } = this.prepareSabrContexts();
440
+ const { selectedFormatIds, updatedBufferedRanges } = this.prepareFormatSelections([selectedVideoFormat, selectedAudioFormat], bufferedRanges);
441
+ return VideoPlaybackAbrRequest.encode({
442
+ clientAbrState: abrState,
443
+ preferredAudioFormatIds: [selectedAudioFormat],
444
+ preferredVideoFormatIds: [selectedVideoFormat],
445
+ preferredSubtitleFormatIds: [],
446
+ selectedFormatIds,
447
+ videoPlaybackUstreamerConfig: base64ToU8(this.videoPlaybackUstreamerConfig),
448
+ streamerContext: {
449
+ sabrContexts,
450
+ unsentSabrContexts,
451
+ poToken: this.poToken ? base64ToU8(this.poToken) : undefined,
452
+ playbackCookie: this.nextRequestPolicy?.playbackCookie ? PlaybackCookie.encode(this.nextRequestPolicy.playbackCookie).finish() : undefined,
453
+ clientInfo: this.clientInfo
454
+ },
455
+ bufferedRanges: updatedBufferedRanges,
456
+ field1000: []
457
+ }).finish();
458
+ }
459
+ /**
460
+ * Prepares SABR context data for the request body.
461
+ * @returns An object containing active and unsent SABR contexts.
462
+ * @private
463
+ */
464
+ prepareSabrContexts() {
465
+ const sabrContexts = [];
466
+ const unsentSabrContexts = [];
467
+ for (const ctxUpdate of this.sabrContexts.values()) {
468
+ if (this.activeSabrContextTypes.has(ctxUpdate.type)) {
469
+ sabrContexts.push(ctxUpdate);
470
+ }
471
+ else {
472
+ unsentSabrContexts.push(ctxUpdate.type);
473
+ }
474
+ }
475
+ return { sabrContexts, unsentSabrContexts };
476
+ }
477
+ /**
478
+ * Prepares format selections and buffered ranges for the request body.
479
+ * @param formats - An array of formats to process.
480
+ * @param currentBufferedRanges - The current buffered ranges to update.
481
+ * @returns An object with selected format IDs and updated buffered ranges.
482
+ * @private
483
+ */
484
+ prepareFormatSelections(formats, currentBufferedRanges) {
485
+ const selectedFormatIds = [];
486
+ const updatedBufferedRanges = [...currentBufferedRanges];
487
+ const formatsInitialized = this.initializedFormatsMap.size > 0;
488
+ for (const format of formats) {
489
+ const formatKey = FormatKeyUtils.fromFormat(format);
490
+ const shouldDiscard = this.formatToDiscard && formatKey === this.formatToDiscard;
491
+ if (shouldDiscard) {
492
+ updatedBufferedRanges.push({
493
+ formatId: format,
494
+ durationMs: MAX_INT32_VALUE,
495
+ startTimeMs: 0,
496
+ startSegmentIndex: MAX_INT32_VALUE,
497
+ endSegmentIndex: MAX_INT32_VALUE,
498
+ timeRange: {
499
+ durationTicks: MAX_INT32_VALUE,
500
+ startTicks: 0,
501
+ timescale: 1000
502
+ }
503
+ });
504
+ }
505
+ // Only add format to selectedFormatIds when either:
506
+ // 1. Formats have been initialized (indicating we've received their metadata).
507
+ // 2. This format should be discarded (we want the server to acknowledge it's fully buffered).
508
+ if (formatsInitialized || shouldDiscard) {
509
+ selectedFormatIds.push(format);
510
+ }
511
+ }
512
+ return { selectedFormatIds, updatedBufferedRanges };
513
+ }
514
+ /**
515
+ * Executes a streaming POST request to the server.
516
+ * @param body - The request body payload.
517
+ * @returns A `Promise` that resolves with the server `Response`.
518
+ * @throws {Error} If the server ABR streaming URL is not configured or the request fails.
519
+ * @private
520
+ */
521
+ async makeStreamingRequest(body) {
522
+ if (!this.serverAbrStreamingUrl) {
523
+ throw new Error('Server ABR streaming URL not configured.');
524
+ }
525
+ const url = new URL(this.serverAbrStreamingUrl);
526
+ url.searchParams.set('rn', this.requestNumber.toString());
527
+ this.abortController = new AbortController();
528
+ const timeoutId = setTimeout(() => this.abortController?.abort(), 60000);
529
+ try {
530
+ return await this.fetchFunction(url, {
531
+ method: 'POST',
532
+ headers: {
533
+ 'content-type': 'application/x-protobuf',
534
+ 'accept-encoding': 'identity',
535
+ 'accept': 'application/vnd.yt-ump'
536
+ },
537
+ body,
538
+ signal: this.abortController.signal
539
+ });
540
+ }
541
+ finally {
542
+ clearTimeout(timeoutId);
543
+ this.requestNumber += 1;
544
+ }
545
+ }
546
+ /**
547
+ * Reads the response body as a stream and processes each UMP part.
548
+ * @param response - The server response to process.
549
+ * @returns A promise that resolves to an array of processed UMP part types.
550
+ * @throws {Error} If the response is invalid, empty, or aborted.
551
+ * @private
552
+ */
553
+ async processStreamingResponse(response) {
554
+ if (!response.ok)
555
+ throw new Error(`Server returned ${response.status} ${response.statusText}`);
556
+ if (response.headers.get('content-type') !== 'application/vnd.yt-ump')
557
+ throw new Error(`Unexpected content type from server: ${response.headers.get('content-type')}`);
558
+ const reader = response.body.getReader();
559
+ let dataReceived = false;
560
+ let partialPart;
561
+ const processedParts = [];
562
+ while (true) {
563
+ if (this.abortController?.signal?.aborted && !this._aborted)
564
+ throw new Error('Stream was aborted.');
565
+ const { done, value } = await reader.read();
566
+ if (done) {
567
+ if (!dataReceived) {
568
+ throw new Error('Received empty response from server.');
569
+ }
570
+ break;
571
+ }
572
+ dataReceived = true;
573
+ let chunk;
574
+ if (partialPart) {
575
+ chunk = partialPart.data;
576
+ chunk.append(value);
577
+ }
578
+ else {
579
+ chunk = new CompositeBuffer([value]);
580
+ }
581
+ const ump = new UmpReader(chunk);
582
+ partialPart = ump.read((part) => {
583
+ processedParts.push(part.type);
584
+ const handler = this.umpPartHandlers.get(part.type);
585
+ if (handler) {
586
+ handler(part);
587
+ }
588
+ });
589
+ }
590
+ this.partialSegmentQueue.clear();
591
+ return processedParts;
592
+ }
593
+ /**
594
+ * Executes a function with automatic retries and exponential backoff.
595
+ * Respects server-specified backoff times from `nextRequestPolicy`.
596
+ * @param fetchFn - The function to execute.
597
+ * @param maxRetries - The maximum number of retry attempts.
598
+ * @returns A promise that resolves to `true` on success, or `false` if all retries fail.
599
+ * @private
600
+ */
601
+ async executeWithRetry(fetchFn, maxRetries) {
602
+ const backoffTimeMs = this.nextRequestPolicy?.backoffTimeMs || 0;
603
+ if (backoffTimeMs > 0) {
604
+ this.logger.debug(TAG, `Respecting server backoff policy: waiting ${backoffTimeMs}ms before request`);
605
+ await wait(backoffTimeMs);
606
+ }
607
+ for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
608
+ try {
609
+ await fetchFn();
610
+ if (this.mediaHeadersProcessed) {
611
+ this.cachedBufferedRanges = undefined;
612
+ }
613
+ return true;
614
+ }
615
+ catch (e) {
616
+ const error = e;
617
+ if (this._aborted) {
618
+ this.logger.debug(TAG, 'Download process aborted, skipping retry.');
619
+ return false;
620
+ }
621
+ if (attempt > maxRetries) {
622
+ this.logger.error(TAG, `Maximum retries (${maxRetries}) exceeded while fetching segment: ${error.message}`);
623
+ this.errorHandler(error, true);
624
+ break;
625
+ }
626
+ const retryBackoffMs = Math.min(BACKOFF_MULTIPLIER * Math.pow(2, attempt - 1), MAX_BACKOFF_MS);
627
+ this.logger.warn(TAG, `Segment fetch attempt ${attempt}/${maxRetries + 1} failed: ${error.message} - retrying in ${retryBackoffMs}ms`);
628
+ await wait(retryBackoffMs);
629
+ }
630
+ }
631
+ return false;
632
+ }
633
+ //#endregion
634
+ //#region --- UMP Part Handlers ---
635
+ /**
636
+ * Handles `FORMAT_INITIALIZATION_METADATA` parts.
637
+ * Creates and stores a new `InitializedFormat` entry.
638
+ * @private
639
+ */
640
+ handleFormatInitializationMetadata(part) {
641
+ const formatInitMetadata = FormatInitializationMetadata.decode(part.data.chunks[0]);
642
+ const formatIdKey = FormatKeyUtils.fromFormatInitializationMetadata(formatInitMetadata);
643
+ const initializedFormat = {
644
+ formatInitializationMetadata: formatInitMetadata,
645
+ downloadedSegments: new Map(),
646
+ lastMediaHeaders: []
647
+ };
648
+ this.initializedFormatsMap.set(formatIdKey, initializedFormat);
649
+ this.logger.debug(TAG, `Initialized format: ${formatIdKey}`);
650
+ this.emit('formatInitialization', initializedFormat);
651
+ }
652
+ /**
653
+ * Handles `NEXT_REQUEST_POLICY` parts.
654
+ * Stores the server's policy for backoff time and playback cookies.
655
+ * @private
656
+ */
657
+ handleNextRequestPolicy(part) {
658
+ this.nextRequestPolicy = NextRequestPolicy.decode(part.data.chunks[0]);
659
+ }
660
+ /**
661
+ * Handles `SABR_ERROR` parts.
662
+ * Throws an error to terminate the current request attempt.
663
+ * @throws {Error} Always throws with the SABR error details.
664
+ * @private
665
+ */
666
+ handleSabrError(part) {
667
+ const sabrError = SabrError.decode(part.data.chunks[0]);
668
+ throw new Error(`SABR Error: ${sabrError.type} - ${sabrError.code}`);
669
+ }
670
+ /**
671
+ * Handles `SABR_REDIRECT` parts.
672
+ * Updates the streaming URL to the new location provided by the server.
673
+ * @private
674
+ */
675
+ handleSabrRedirect(part) {
676
+ const sabrRedirect = SabrRedirect.decode(part.data.chunks[0]);
677
+ this.serverAbrStreamingUrl = sabrRedirect.url;
678
+ this.logger.debug(TAG, `Redirecting to ${this.serverAbrStreamingUrl}`);
679
+ }
680
+ /**
681
+ * Handles `SABR_CONTEXT_UPDATE` parts.
682
+ * Updates the client's context state based on server instructions.
683
+ * @private
684
+ */
685
+ handleSabrContextUpdate(part) {
686
+ const sabrContextUpdate = SabrContextUpdate.decode(part.data.chunks[0]);
687
+ if (sabrContextUpdate.type !== undefined && sabrContextUpdate.value?.length) {
688
+ if (sabrContextUpdate.writePolicy === SabrContextWritePolicy.KEEP_EXISTING &&
689
+ this.sabrContexts.has(sabrContextUpdate.type)) {
690
+ this.logger.debug(TAG, `Skipping SABR context update for type ${sabrContextUpdate.type}`);
691
+ return;
692
+ }
693
+ this.sabrContexts.set(sabrContextUpdate.type, sabrContextUpdate);
694
+ if (sabrContextUpdate.sendByDefault) {
695
+ this.activeSabrContextTypes.add(sabrContextUpdate.type);
696
+ }
697
+ this.logger.debug(TAG, `Received SABR context update (type: ${sabrContextUpdate.type}, sendByDefault: ${sabrContextUpdate.sendByDefault})`);
698
+ }
699
+ }
700
+ /**
701
+ * Handles `SABR_CONTEXT_SENDING_POLICY` parts.
702
+ * Updates which contexts should be sent in future requests.
703
+ * @private
704
+ */
705
+ handleSabrContextSendingPolicy(part) {
706
+ const sabrContextSendingPolicy = SabrContextSendingPolicy.decode(part.data.chunks[0]);
707
+ for (const startPolicy of sabrContextSendingPolicy.startPolicy) {
708
+ if (!this.activeSabrContextTypes.has(startPolicy)) {
709
+ this.activeSabrContextTypes.add(startPolicy);
710
+ this.logger.debug(TAG, `Activated SABR context for type ${startPolicy}`);
711
+ }
712
+ }
713
+ for (const stopPolicy of sabrContextSendingPolicy.stopPolicy) {
714
+ if (this.activeSabrContextTypes.has(stopPolicy)) {
715
+ this.activeSabrContextTypes.delete(stopPolicy);
716
+ this.logger.debug(TAG, `Deactivated SABR context for type ${stopPolicy}`);
717
+ }
718
+ }
719
+ for (const discardPolicy of sabrContextSendingPolicy.discardPolicy) {
720
+ if (this.sabrContexts.has(discardPolicy)) {
721
+ this.sabrContexts.delete(discardPolicy);
722
+ this.logger.debug(TAG, `Discarded SABR context for type ${discardPolicy}`);
723
+ }
724
+ }
725
+ }
726
+ /**
727
+ * Handles `STREAM_PROTECTION_STATUS` parts.
728
+ * Emits updates and handles critical statuses like required attestation.
729
+ * @throws {Error} If attestation is required (status 3).
730
+ * @private
731
+ */
732
+ handleStreamProtectionStatus(part) {
733
+ this.streamProtectionStatus = StreamProtectionStatus.decode(part.data.chunks[0]);
734
+ this.emit('streamProtectionStatusUpdate', this.streamProtectionStatus);
735
+ if (this.streamProtectionStatus.status === 3) {
736
+ throw new Error('Cannot proceed with stream: attestation required');
737
+ }
738
+ else if (this.streamProtectionStatus.status === 2) {
739
+ this.logger.warn(TAG, 'Attestation pending.');
740
+ }
741
+ }
742
+ /**
743
+ * Handles `RELOAD_PLAYER_RESPONSE` parts.
744
+ * Emits an event with reload parameters and terminates the session.
745
+ * @throws {Error} Always throws to terminate the current streaming session.
746
+ * @private
747
+ */
748
+ handleReloadPlayerResponse(part) {
749
+ const reloadPlaybackContext = ReloadPlaybackContext.decode(part.data.chunks[0]);
750
+ const errorMessage = 'Player response reload requested by server';
751
+ this.logger.debug(TAG, `${errorMessage} (token: ${reloadPlaybackContext.reloadPlaybackParams?.token}`);
752
+ this.emit('reloadPlayerResponse', reloadPlaybackContext);
753
+ throw new Error(errorMessage);
754
+ }
755
+ /**
756
+ * Handles `MEDIA_HEADER` parts.
757
+ * Creates an entry in the `partialSegmentQueue` for the upcoming media chunks.
758
+ * @private
759
+ */
760
+ handleMediaHeader(part) {
761
+ const mediaHeader = MediaHeader.decode(part.data.chunks[0]);
762
+ const headerId = mediaHeader.headerId || 0;
763
+ const formatIdKey = FormatKeyUtils.fromMediaHeader(mediaHeader);
764
+ const segmentNumber = mediaHeader.isInitSeg ? 0 : mediaHeader.sequenceNumber;
765
+ const durationMs = mediaHeader.timeRange ? Math.ceil(((mediaHeader.timeRange.durationTicks || 0) / (mediaHeader.timeRange.timescale || 0)) * 1000) : mediaHeader.durationMs || 0;
766
+ const initializedFormat = this.initializedFormatsMap.get(formatIdKey);
767
+ if (!initializedFormat) {
768
+ this.logger.warn(TAG, `No initialized format found for key: ${formatIdKey} (segment ${segmentNumber})`);
769
+ return;
770
+ }
771
+ const mediaType = getMediaType(initializedFormat);
772
+ if (initializedFormat.downloadedSegments.has(segmentNumber)) {
773
+ this.logger.debug(TAG, `Segment ${formatIdKey} (segment: ${segmentNumber}) already downloaded. Ignoring.`);
774
+ return;
775
+ }
776
+ this.partialSegmentQueue.set(headerId, {
777
+ formatIdKey,
778
+ segmentNumber,
779
+ durationMs,
780
+ mediaHeader,
781
+ bufferedChunks: []
782
+ });
783
+ this.logger.debug(TAG, `Enqueued ${mediaType} segment ${segmentNumber} (Header ID: ${headerId}, key: ${formatIdKey}, duration: ${durationMs}ms)`);
784
+ }
785
+ /**
786
+ * Handles `MEDIA` parts.
787
+ * Buffers media data chunks associated with a specific header ID.
788
+ * @private
789
+ */
790
+ handleMedia(part) {
791
+ const headerId = part.data.getUint8(0);
792
+ const segment = this.partialSegmentQueue.get(headerId);
793
+ if (!segment) {
794
+ this.logger.debug(TAG, `Received Media part for an unknown Header ID: ${headerId}`);
795
+ return;
796
+ }
797
+ const initializedFormat = this.initializedFormatsMap.get(segment.formatIdKey);
798
+ if (!initializedFormat) {
799
+ this.logger.warn(TAG, `No initialized format found for key ${segment.formatIdKey} (segment ${segment.segmentNumber})`);
800
+ return;
801
+ }
802
+ const dataBuffer = part.data.split(1).remainingBuffer;
803
+ for (const chunk of dataBuffer.chunks) {
804
+ segment.bufferedChunks.push(chunk);
805
+ }
806
+ }
807
+ /**
808
+ * Handles `MEDIA_END` parts.
809
+ * Finalizes a segment, enqueues its data to the appropriate stream, and updates tracking.
810
+ * @private
811
+ */
812
+ handleMediaEnd(part) {
813
+ const headerId = part.data.getUint8(0);
814
+ const segment = this.partialSegmentQueue.get(headerId);
815
+ if (!segment) {
816
+ this.logger.debug(TAG, `Received MediaEnd for an unknown Header ID: ${headerId}`);
817
+ return;
818
+ }
819
+ const loadedBytes = segment.bufferedChunks.reduce((sum, chunk) => sum + chunk.length, 0);
820
+ if (loadedBytes !== segment.mediaHeader.contentLength) {
821
+ this.logger.warn(TAG, `Content length mismatch for segment ${segment.segmentNumber} (Header ID: ${headerId}, key: ${segment.formatIdKey}, expected: ${segment.mediaHeader.contentLength}, received: ${loadedBytes})`);
822
+ this.partialSegmentQueue.delete(headerId);
823
+ return;
824
+ }
825
+ const initializedFormat = this.initializedFormatsMap.get(segment.formatIdKey);
826
+ if (initializedFormat) {
827
+ const mediaType = getMediaType(initializedFormat);
828
+ if (segment.bufferedChunks.length) {
829
+ for (const chunk of segment.bufferedChunks) {
830
+ if (mediaType === 'audio') {
831
+ this.audioController?.enqueue(chunk);
832
+ }
833
+ else {
834
+ this.videoController?.enqueue(chunk);
835
+ }
836
+ }
837
+ }
838
+ this.logger.debug(TAG, `Received MediaEnd for ${mediaType} segment ${segment.segmentNumber} (Header ID: ${headerId}, key: ${segment.formatIdKey})`);
839
+ segment.bufferedChunks.length = 0; // Avoid weird mem leaks...
840
+ segment.bufferedChunks = [];
841
+ initializedFormat.lastMediaHeaders.push(segment.mediaHeader);
842
+ initializedFormat.downloadedSegments.set(segment.segmentNumber, segment);
843
+ this.partialSegmentQueue.delete(headerId);
844
+ }
845
+ }
846
+ //#endregion
847
+ //#region --- Stream Validation and Integrity Checks ---
848
+ /**
849
+ * Validates and corrects the stream duration based on format initialization metadata.
850
+ * @param formatInitializationMetadata - The metadata from an initialized format.
851
+ * @private
852
+ */
853
+ validateAndCorrectDuration(formatInitializationMetadata) {
854
+ const durationUnits = formatInitializationMetadata.durationUnits || 0;
855
+ const durationTimescale = formatInitializationMetadata.durationTimescale || 0;
856
+ if (durationTimescale === 0) {
857
+ this.logger.warn(TAG, 'Invalid timescale (0) in format initialization metadata');
858
+ return;
859
+ }
860
+ const expectedDuration = Math.trunc(durationUnits / (durationTimescale / 1000));
861
+ if (this.durationMs !== expectedDuration) {
862
+ this.durationMs = expectedDuration;
863
+ this.logger.debug(TAG, `Corrected stream duration to ${this.durationMs}ms based on format initialization metadata`);
864
+ }
865
+ }
866
+ /**
867
+ * Validates downloaded segments for completeness and consistency after the stream finishes.
868
+ * Checks for duration coverage, missing segments, and duplicates.
869
+ * @private
870
+ */
871
+ validateDownloadedSegments() {
872
+ for (const [formatIdKey, initializedFormat] of this.initializedFormatsMap.entries()) {
873
+ if (formatIdKey === this.formatToDiscard) {
874
+ this.logger.debug(TAG, `Skipping validation for discarded format: ${formatIdKey}`);
875
+ continue;
876
+ }
877
+ const totalDuration = getTotalDownloadedDuration(initializedFormat);
878
+ const durationUnits = initializedFormat.formatInitializationMetadata.durationUnits || 0;
879
+ const durationTimescale = initializedFormat.formatInitializationMetadata.durationTimescale || 0;
880
+ const expectedDuration = durationTimescale ? durationUnits / (durationTimescale / 1000) : 0;
881
+ const durationMismatch = Math.abs(totalDuration - expectedDuration);
882
+ if (expectedDuration > 0 && durationMismatch > expectedDuration * 0.01) {
883
+ const durationCoverage = Math.round((totalDuration / expectedDuration) * 100);
884
+ this.logger.warn(TAG, `Incomplete stream for format ${formatIdKey}: downloaded ${totalDuration}ms (${durationCoverage}%), expected ${expectedDuration}ms`);
885
+ }
886
+ const segments = Array.from(initializedFormat.downloadedSegments.entries());
887
+ if (segments.length === 0)
888
+ continue;
889
+ segments.sort(([numA], [numB]) => numA - numB);
890
+ const expectedSegmentCount = initializedFormat.formatInitializationMetadata.endSegmentNumber;
891
+ const missingSegments = [];
892
+ // Find all missing segments in the expected range.
893
+ for (let i = 0; i <= expectedSegmentCount; i++) {
894
+ if (!initializedFormat.downloadedSegments.has(i)) {
895
+ missingSegments.push(i);
896
+ }
897
+ }
898
+ // Check for duplicate segments (should not happen, but good to validate).
899
+ const uniqueSegmentCount = new Set(segments.map(([num]) => num)).size;
900
+ const hasDuplicates = uniqueSegmentCount !== segments.length;
901
+ if (missingSegments.length > 0) {
902
+ const message = `Format ${formatIdKey}: Missing segments: ${missingSegments.join(', ')}. ` +
903
+ `Expected range: 0-${expectedSegmentCount}. `;
904
+ this.logger.warn(TAG, message);
905
+ this.errorHandler(new Error(message), true);
906
+ }
907
+ else {
908
+ this.logger.debug(TAG, `Format ${formatIdKey}: All ${expectedSegmentCount} segments present (100% coverage)`);
909
+ }
910
+ if (hasDuplicates) {
911
+ const message = `Format ${formatIdKey}: Found duplicate segment numbers (${segments.length} segments but ${uniqueSegmentCount} unique numbers)`;
912
+ this.logger.warn(TAG, message);
913
+ this.errorHandler(new Error(message), true);
914
+ }
915
+ }
916
+ }
917
+ //#endregion
918
+ /**
919
+ * Resets the internal state of the stream.
920
+ * Clears all maps, resets counters, and re-initializes the progress tracker.
921
+ * @private
922
+ */
923
+ resetState() {
924
+ this.initializedFormatsMap.clear();
925
+ this.partialSegmentQueue.clear();
926
+ this.activeSabrContextTypes.clear();
927
+ this.sabrContexts.clear();
928
+ this.nextRequestPolicy = undefined;
929
+ this.mainFormat = undefined;
930
+ this.requestNumber = 0;
931
+ this.cachedBufferedRanges = undefined;
932
+ this.mediaHeadersProcessed = false;
933
+ this.streamProtectionStatus = undefined;
934
+ this.formatToDiscard = undefined;
935
+ this.abortController = undefined;
936
+ this.progressTracker = {
937
+ lastProgressTime: Date.now(),
938
+ lastDownloadedDuration: 0,
939
+ stallCount: 0
940
+ };
941
+ }
942
+ /**
943
+ * Handles errors during the streaming process.
944
+ * @param error - The error that occurred.
945
+ * @param notifyControllers - Whether to propagate the error to the stream controllers.
946
+ * @private
947
+ */
948
+ errorHandler(error, notifyControllers = true) {
949
+ this.resetState();
950
+ this.logger.error(TAG, `Stream error: ${error.message}`);
951
+ if (notifyControllers) {
952
+ this._errored = true;
953
+ this.videoController?.error(error);
954
+ this.audioController?.error(error);
955
+ }
956
+ }
957
+ }
958
+ //# sourceMappingURL=SabrStream.js.map