media-segment-request.js
13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
import videojs from 'video.js';
import { createTransferableMessage } from './bin-utils';
export const REQUEST_ERRORS = {
FAILURE: 2,
TIMEOUT: -101,
ABORTED: -102
};
/**
* Turns segment byterange into a string suitable for use in
* HTTP Range requests
*
* @param {Object} byterange - an object with two values defining the start and end
* of a byte-range
*/
const byterangeStr = function(byterange) {
let byterangeStart;
let byterangeEnd;
// `byterangeEnd` is one less than `offset + length` because the HTTP range
// header uses inclusive ranges
byterangeEnd = byterange.offset + byterange.length - 1;
byterangeStart = byterange.offset;
return 'bytes=' + byterangeStart + '-' + byterangeEnd;
};
/**
* Defines headers for use in the xhr request for a particular segment.
*
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
*/
const segmentXhrHeaders = function(segment) {
let headers = {};
if (segment.byterange) {
headers.Range = byterangeStr(segment.byterange);
}
return headers;
};
/**
* Abort all requests
*
* @param {Object} activeXhrs - an object that tracks all XHR requests
*/
const abortAll = (activeXhrs) => {
activeXhrs.forEach((xhr) => {
xhr.abort();
});
};
/**
* Gather important bandwidth stats once a request has completed
*
* @param {Object} request - the XHR request from which to gather stats
*/
const getRequestStats = (request) => {
return {
bandwidth: request.bandwidth,
bytesReceived: request.bytesReceived || 0,
roundTripTime: request.roundTripTime || 0
};
};
/**
* If possible gather bandwidth stats as a request is in
* progress
*
* @param {Event} progressEvent - an event object from an XHR's progress event
*/
const getProgressStats = (progressEvent) => {
const request = progressEvent.target;
const roundTripTime = Date.now() - request.requestTime;
const stats = {
bandwidth: Infinity,
bytesReceived: 0,
roundTripTime: roundTripTime || 0
};
stats.bytesReceived = progressEvent.loaded;
// This can result in Infinity if stats.roundTripTime is 0 but that is ok
// because we should only use bandwidth stats on progress to determine when
// abort a request early due to insufficient bandwidth
stats.bandwidth = Math.floor((stats.bytesReceived / stats.roundTripTime) * 8 * 1000);
return stats;
};
/**
* Handle all error conditions in one place and return an object
* with all the information
*
* @param {Error|null} error - if non-null signals an error occured with the XHR
* @param {Object} request - the XHR request that possibly generated the error
*/
const handleErrors = (error, request) => {
if (request.timedout) {
return {
status: request.status,
message: 'HLS request timed-out at URL: ' + request.uri,
code: REQUEST_ERRORS.TIMEOUT,
xhr: request
};
}
if (request.aborted) {
return {
status: request.status,
message: 'HLS request aborted at URL: ' + request.uri,
code: REQUEST_ERRORS.ABORTED,
xhr: request
};
}
if (error) {
return {
status: request.status,
message: 'HLS request errored at URL: ' + request.uri,
code: REQUEST_ERRORS.FAILURE,
xhr: request
};
}
return null;
};
/**
* Handle responses for key data and convert the key data to the correct format
* for the decryption step later
*
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
* @param {Function} finishProcessingFn - a callback to execute to continue processing
* this request
*/
const handleKeyResponse = (segment, finishProcessingFn) => (error, request) => {
const response = request.response;
const errorObj = handleErrors(error, request);
if (errorObj) {
return finishProcessingFn(errorObj, segment);
}
if (response.byteLength !== 16) {
return finishProcessingFn({
status: request.status,
message: 'Invalid HLS key at URL: ' + request.uri,
code: REQUEST_ERRORS.FAILURE,
xhr: request
}, segment);
}
const view = new DataView(response);
segment.key.bytes = new Uint32Array([
view.getUint32(0),
view.getUint32(4),
view.getUint32(8),
view.getUint32(12)
]);
return finishProcessingFn(null, segment);
};
/**
* Handle init-segment responses
*
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
* @param {Function} finishProcessingFn - a callback to execute to continue processing
* this request
*/
const handleInitSegmentResponse = (segment, finishProcessingFn) => (error, request) => {
const response = request.response;
const errorObj = handleErrors(error, request);
if (errorObj) {
return finishProcessingFn(errorObj, segment);
}
// stop processing if received empty content
if (response.byteLength === 0) {
return finishProcessingFn({
status: request.status,
message: 'Empty HLS segment content at URL: ' + request.uri,
code: REQUEST_ERRORS.FAILURE,
xhr: request
}, segment);
}
segment.map.bytes = new Uint8Array(request.response);
return finishProcessingFn(null, segment);
};
/**
* Response handler for segment-requests being sure to set the correct
* property depending on whether the segment is encryped or not
* Also records and keeps track of stats that are used for ABR purposes
*
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
* @param {Function} finishProcessingFn - a callback to execute to continue processing
* this request
*/
const handleSegmentResponse = (segment, finishProcessingFn) => (error, request) => {
const response = request.response;
const errorObj = handleErrors(error, request);
if (errorObj) {
return finishProcessingFn(errorObj, segment);
}
// stop processing if received empty content
if (response.byteLength === 0) {
return finishProcessingFn({
status: request.status,
message: 'Empty HLS segment content at URL: ' + request.uri,
code: REQUEST_ERRORS.FAILURE,
xhr: request
}, segment);
}
segment.stats = getRequestStats(request);
if (segment.key) {
segment.encryptedBytes = new Uint8Array(request.response);
} else {
segment.bytes = new Uint8Array(request.response);
}
return finishProcessingFn(null, segment);
};
/**
* Decrypt the segment via the decryption web worker
*
* @param {WebWorker} decrypter - a WebWorker interface to AES-128 decryption routines
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
* @param {Function} doneFn - a callback that is executed after decryption has completed
*/
const decryptSegment = (decrypter, segment, doneFn) => {
const decryptionHandler = (event) => {
if (event.data.source === segment.requestId) {
decrypter.removeEventListener('message', decryptionHandler);
const decrypted = event.data.decrypted;
segment.bytes = new Uint8Array(decrypted.bytes,
decrypted.byteOffset,
decrypted.byteLength);
return doneFn(null, segment);
}
};
decrypter.addEventListener('message', decryptionHandler);
// this is an encrypted segment
// incrementally decrypt the segment
decrypter.postMessage(createTransferableMessage({
source: segment.requestId,
encrypted: segment.encryptedBytes,
key: segment.key.bytes,
iv: segment.key.iv
}), [
segment.encryptedBytes.buffer,
segment.key.bytes.buffer
]);
};
/**
* The purpose of this function is to get the most pertinent error from the
* array of errors.
* For instance if a timeout and two aborts occur, then the aborts were
* likely triggered by the timeout so return that error object.
*/
const getMostImportantError = (errors) => {
return errors.reduce((prev, err) => {
return err.code > prev.code ? err : prev;
});
};
/**
* This function waits for all XHRs to finish (with either success or failure)
* before continueing processing via it's callback. The function gathers errors
* from each request into a single errors array so that the error status for
* each request can be examined later.
*
* @param {Object} activeXhrs - an object that tracks all XHR requests
* @param {WebWorker} decrypter - a WebWorker interface to AES-128 decryption routines
* @param {Function} doneFn - a callback that is executed after all resources have been
* downloaded and any decryption completed
*/
const waitForCompletion = (activeXhrs, decrypter, doneFn) => {
let errors = [];
let count = 0;
return (error, segment) => {
if (error) {
// If there are errors, we have to abort any outstanding requests
abortAll(activeXhrs);
errors.push(error);
}
count += 1;
if (count === activeXhrs.length) {
// Keep track of when *all* of the requests have completed
segment.endOfAllRequests = Date.now();
if (errors.length > 0) {
const worstError = getMostImportantError(errors);
return doneFn(worstError, segment);
}
if (segment.encryptedBytes) {
return decryptSegment(decrypter, segment, doneFn);
}
// Otherwise, everything is ready just continue
return doneFn(null, segment);
}
};
};
/**
* Simple progress event callback handler that gathers some stats before
* executing a provided callback with the `segment` object
*
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
* @param {Function} progressFn - a callback that is executed each time a progress event
* is received
* @param {Event} event - the progress event object from XMLHttpRequest
*/
const handleProgress = (segment, progressFn) => (event) => {
segment.stats = videojs.mergeOptions(segment.stats, getProgressStats(event));
// record the time that we receive the first byte of data
if (!segment.stats.firstBytesReceivedAt && segment.stats.bytesReceived) {
segment.stats.firstBytesReceivedAt = Date.now();
}
return progressFn(event, segment);
};
/**
* Load all resources and does any processing necessary for a media-segment
*
* Features:
* decrypts the media-segment if it has a key uri and an iv
* aborts *all* requests if *any* one request fails
*
* The segment object, at minimum, has the following format:
* {
* resolvedUri: String,
* [byterange]: {
* offset: Number,
* length: Number
* },
* [key]: {
* resolvedUri: String
* [byterange]: {
* offset: Number,
* length: Number
* },
* iv: {
* bytes: Uint32Array
* }
* },
* [map]: {
* resolvedUri: String,
* [byterange]: {
* offset: Number,
* length: Number
* },
* [bytes]: Uint8Array
* }
* }
* ...where [name] denotes optional properties
*
* @param {Function} xhr - an instance of the xhr wrapper in xhr.js
* @param {Object} xhrOptions - the base options to provide to all xhr requests
* @param {WebWorker} decryptionWorker - a WebWorker interface to AES-128
* decryption routines
* @param {Object} segment - a simplified copy of the segmentInfo object
* from SegmentLoader
* @param {Function} progressFn - a callback that receives progress events from the main
* segment's xhr request
* @param {Function} doneFn - a callback that is executed only once all requests have
* succeeded or failed
* @returns {Function} a function that, when invoked, immediately aborts all
* outstanding requests
*/
export const mediaSegmentRequest = (xhr,
xhrOptions,
decryptionWorker,
segment,
progressFn,
doneFn) => {
const activeXhrs = [];
const finishProcessingFn = waitForCompletion(activeXhrs, decryptionWorker, doneFn);
// optionally, request the decryption key
if (segment.key) {
const keyRequestOptions = videojs.mergeOptions(xhrOptions, {
uri: segment.key.resolvedUri,
responseType: 'arraybuffer'
});
const keyRequestCallback = handleKeyResponse(segment, finishProcessingFn);
const keyXhr = xhr(keyRequestOptions, keyRequestCallback);
activeXhrs.push(keyXhr);
}
// optionally, request the associated media init segment
if (segment.map &&
!segment.map.bytes) {
const initSegmentOptions = videojs.mergeOptions(xhrOptions, {
uri: segment.map.resolvedUri,
responseType: 'arraybuffer',
headers: segmentXhrHeaders(segment.map)
});
const initSegmentRequestCallback = handleInitSegmentResponse(segment,
finishProcessingFn);
const initSegmentXhr = xhr(initSegmentOptions, initSegmentRequestCallback);
activeXhrs.push(initSegmentXhr);
}
const segmentRequestOptions = videojs.mergeOptions(xhrOptions, {
uri: segment.resolvedUri,
responseType: 'arraybuffer',
headers: segmentXhrHeaders(segment)
});
const segmentRequestCallback = handleSegmentResponse(segment, finishProcessingFn);
const segmentXhr = xhr(segmentRequestOptions, segmentRequestCallback);
segmentXhr.addEventListener('progress', handleProgress(segment, progressFn));
activeXhrs.push(segmentXhr);
return () => abortAll(activeXhrs);
};