From 197bab7b94ea259a2952dbed3ba27431a1998966 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Thu, 2 May 2019 17:04:34 -0400 Subject: [PATCH] #446 --- README.md | 22 +++- src/bin-utils.js | 7 ++ src/media-segment-request.js | 8 +- src/segment-loader.js | 56 ++++++++- src/videojs-http-streaming.js | 4 +- test/configuration.test.js | 4 + test/master-playlist-controller.test.js | 35 ++++++ test/media-segment-request.test.js | 53 ++++++++- test/segment-loader.test.js | 140 ++++++++++++++++++++++ test/test-helpers.js | 8 +- test/videojs-http-streaming.test.js | 151 ++++++++++++++++++++++-- 11 files changed, 465 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index c28d62432..5c042d197 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Video.js Compatibility: 6.0, 7.0 - [Source](#source) - [List](#list) - [withCredentials](#withcredentials) + - [handleManifestRedirects](#handlemanifestredirects) - [useCueTags](#usecuetags) - [overrideNative](#overridenative) - [blacklistDuration](#blacklistduration) @@ -51,6 +52,7 @@ Video.js Compatibility: 6.0, 7.0 - [allowSeeksWithinUnsafeLiveWindow](#allowseekswithinunsafelivewindow) - [customTagParsers](#customtagparsers) - [customTagMappers](#customtagmappers) + - [cacheEncryptionKeys](#cacheencryptionkeys) - [Runtime Properties](#runtime-properties) - [hls.playlists.master](#hlsplaylistsmaster) - [hls.playlists.media](#hlsplaylistsmedia) @@ -178,7 +180,7 @@ You can deploy a single HLS stream, code against the regular HTML5 video APIs, and create a fast, high-quality video experience across all the big web device categories. -Check out the [full documentation](docs/intro.md) for details on how HLS works +Check out the [full documentation](docs/README.md) for details on how HLS works and advanced configuration. A description of the [adaptive switching behavior](docs/bitrate-switching.md) is available, too. @@ -258,6 +260,16 @@ is set to `true`. See html5rocks's [article](http://www.html5rocks.com/en/tutorials/cors/) for more info. +##### handleManifestRedirects +* Type: `boolean` +* Default: `false` +* can be used as a source option +* can be used as an initialization option + +When the `handleManifestRedirects` property is set to `true`, manifest requests +which are redirected will have their URL updated to the new URL for future +requests. + ##### useCueTags * Type: `boolean` * can be used as an initialization option @@ -408,6 +420,14 @@ With `customTagParsers` you can pass an array of custom m3u8 tag parser objects. Similar to `customTagParsers`, with `customTagMappers` you can pass an array of custom m3u8 tag mapper objects. See https://github.com/videojs/m3u8-parser#custom-parsers +##### cacheEncryptionKeys +* Type: `boolean` +* can be used as a source option +* can be used as an initialization option + +This option forces the player to cache AES-128 encryption keys internally instead of requesting the key alongside every segment request. +This option defaults to `false`. + ### Runtime Properties Runtime properties are attached to the tech object when HLS is in use. You can get a reference to the HLS source handler like this: diff --git a/src/bin-utils.js b/src/bin-utils.js index 546ff7ad5..8e0f3c591 100644 --- a/src/bin-utils.js +++ b/src/bin-utils.js @@ -75,6 +75,13 @@ export const initSegmentId = function(initSegment) { ].join(','); }; +/** + * Returns a unique string identifier for a media segment key. + */ +export const segmentKeyId = function(key) { + return key.resolvedUri; +}; + /** * utils to help dump binary data to the console */ diff --git a/src/media-segment-request.js b/src/media-segment-request.js index b2794fe06..d210b0958 100644 --- a/src/media-segment-request.js +++ b/src/media-segment-request.js @@ -474,16 +474,18 @@ const decryptSegment = ({ decryptionWorker.addEventListener('message', decryptionHandler); + const keyBytes = segment.key.bytes.slice(); + // this is an encrypted segment // incrementally decrypt the segment decryptionWorker.postMessage(createTransferableMessage({ source: segment.requestId, encrypted: segment.encryptedBytes, - key: segment.key.bytes, + key: keyBytes, iv: segment.key.iv }), [ segment.encryptedBytes.buffer, - segment.key.bytes.buffer + keyBytes.buffer ]); }; @@ -723,7 +725,7 @@ export const mediaSegmentRequest = ({ }); // optionally, request the decryption key - if (segment.key) { + if (segment.key && !segment.key.bytes) { const keyRequestOptions = videojs.mergeOptions(xhrOptions, { uri: segment.key.resolvedUri, responseType: 'arraybuffer' diff --git a/src/segment-loader.js b/src/segment-loader.js index abc4fcf2c..44cdc3b06 100644 --- a/src/segment-loader.js +++ b/src/segment-loader.js @@ -5,7 +5,7 @@ import Playlist from './playlist'; import videojs from 'video.js'; import Config from './config'; import window from 'global/window'; -import { initSegmentId } from './bin-utils'; +import { initSegmentId, segmentKeyId } from './bin-utils'; import { mediaSegmentRequest, REQUEST_ERRORS } from './media-segment-request'; import TransmuxWorker from 'worker!./transmuxer-worker.worker.js'; import segmentTransmuxer from './segment-transmuxer'; @@ -218,6 +218,11 @@ export default class SegmentLoader extends videojs.EventTarget { // Fragmented mp4 playback this.activeInitSegmentId_ = null; this.initSegments_ = {}; + + // HLSe playback + this.cacheEncryptionKeys_ = settings.cacheEncryptionKeys; + this.keyCache_ = {}; + // Fmp4 CaptionParser this.captionParser_ = new CaptionParser(); @@ -450,6 +455,44 @@ export default class SegmentLoader extends videojs.EventTarget { return storedMap || map; } + /** + * Gets and sets key for the provided key + * + * @param {Object} key + * The key object representing the key to get or set + * @param {Boolean=} set + * If true, the key for the provided key should be saved + * @return {Object} + * Key object for desired key + */ + segmentKey(key, set = false) { + if (!key) { + return null; + } + + const id = segmentKeyId(key); + let storedKey = this.keyCache_[id]; + + // TODO: We should use the HTTP Expires header to invalidate our cache per + // https://tools.ietf.org/html/draft-pantos-http-live-streaming-23#section-6.2.3 + if (this.cacheEncryptionKeys_ && set && !storedKey && key.bytes) { + this.keyCache_[id] = storedKey = { + resolvedUri: key.resolvedUri, + bytes: key.bytes + }; + } + + const result = { + resolvedUri: (storedKey || key).resolvedUri + }; + + if (storedKey) { + result.bytes = storedKey.bytes; + } + + return result; + } + /** * Returns true if all configuration required for loading is present, otherwise false. * @@ -1655,16 +1698,19 @@ export default class SegmentLoader extends videojs.EventTarget { 0, 0, 0, segmentInfo.mediaIndex + segmentInfo.playlist.mediaSequence ]); - simpleSegment.key = { - resolvedUri: segment.key.resolvedUri, - iv - }; + simpleSegment.key = this.segmentKey(segment.key); + simpleSegment.key.iv = iv; } if (segment.map) { simpleSegment.map = this.initSegmentForMap(segment.map); } + // if this request included a segment key, save that data in the cache + if (simpleSegment.key) { + this.segmentKey(simpleSegment.key, true); + } + return simpleSegment; } diff --git a/src/videojs-http-streaming.js b/src/videojs-http-streaming.js index 5afec8794..b2a7b2b41 100644 --- a/src/videojs-http-streaming.js +++ b/src/videojs-http-streaming.js @@ -404,6 +404,7 @@ class HlsHandler extends Component { this.options_.useBandwidthFromLocalStorage || false; this.options_.customTagParsers = this.options_.customTagParsers || []; this.options_.customTagMappers = this.options_.customTagMappers || []; + this.options_.cacheEncryptionKeys = this.options_.cacheEncryptionKeys || false; if (typeof this.options_.blacklistDuration !== 'number') { this.options_.blacklistDuration = 5 * 60; @@ -443,7 +444,8 @@ class HlsHandler extends Component { 'smoothQualityChange', 'customTagParsers', 'customTagMappers', - 'handleManifestRedirects' + 'handleManifestRedirects', + 'cacheEncryptionKeys' ].forEach((option) => { if (typeof this.source_[option] !== 'undefined') { this.options_[option] = this.source_[option]; diff --git a/test/configuration.test.js b/test/configuration.test.js index 979a52079..c48dc7ea7 100644 --- a/test/configuration.test.js +++ b/test/configuration.test.js @@ -57,6 +57,10 @@ const options = [{ return '#FOO'; } }] +}, { + name: 'cacheEncryptionKeys', + default: false, + test: true }]; const CONFIG_KEYS = Object.keys(Config); diff --git a/test/master-playlist-controller.test.js b/test/master-playlist-controller.test.js index a9d868b79..6b8069c77 100644 --- a/test/master-playlist-controller.test.js +++ b/test/master-playlist-controller.test.js @@ -176,6 +176,41 @@ QUnit.test('creates appropriate PlaylistLoader for sourceType', function(assert) 'created a dash playlist loader'); }); +QUnit.test('passes options to SegmentLoader', function(assert) { + const options = { + url: 'test', + tech: this.player.tech_ + }; + + let controller = new MasterPlaylistController(options); + + assert.notOk(controller.mainSegmentLoader_.bandwidth, "bandwidth won't be set by default"); + assert.notOk(controller.mainSegmentLoader_.sourceType_, "sourceType won't be set by default"); + assert.notOk(controller.mainSegmentLoader_.cacheEncryptionKeys_, "cacheEncryptionKeys won't be set by default"); + + controller = new MasterPlaylistController(Object.assign({ + bandwidth: 3, + cacheEncryptionKeys: true, + sourceType: 'fake-type' + }, options)); + + assert.strictEqual( + controller.mainSegmentLoader_.bandwidth, + 3, + 'bandwidth will be set' + ); + assert.strictEqual( + controller.mainSegmentLoader_.sourceType_, + 'fake-type', + 'sourceType will be set' + ); + assert.strictEqual( + controller.mainSegmentLoader_.cacheEncryptionKeys_, + true, + 'cacheEncryptionKeys will be set' + ); +}); + QUnit.test('resets SegmentLoader when seeking out of buffer', function(assert) { let resets = 0; diff --git a/test/media-segment-request.test.js b/test/media-segment-request.test.js index b2504bce4..8f9a710c7 100644 --- a/test/media-segment-request.test.js +++ b/test/media-segment-request.test.js @@ -356,7 +356,6 @@ QUnit.test('the key response is converted to the correct format', function(asser QUnit.test('segment with key has bytes decrypted', function(assert) { const done = assert.async(); - assert.expect(8); mediaSegmentRequest({ xhr: this.xhr, xhrOptions: this.xhrOptions, @@ -375,6 +374,12 @@ QUnit.test('segment with key has bytes decrypted', function(assert) { doneFn: (error, segmentData) => { assert.notOk(error, 'there are no errors'); assert.ok(segmentData.bytes, 'decrypted bytes in segment'); + assert.ok(segmentData.key.bytes, 'key bytes in segment'); + assert.equal( + segmentData.key.bytes.buffer.byteLength, + 16, + 'key bytes are readable' + ); // verify stats assert.equal(segmentData.stats.bytesReceived, 8, '8 bytes'); @@ -399,6 +404,52 @@ QUnit.test('segment with key has bytes decrypted', function(assert) { this.clock.tick(100); }); +QUnit.test('segment with key bytes does not request key again', function(assert) { + const done = assert.async(); + + mediaSegmentRequest( + this.xhr, + this.xhrOptions, + this.realDecrypter, + this.noop, + { + resolvedUri: '0-test.ts', + key: { + resolvedUri: '0-key.php', + bytes: new Uint32Array([0, 2, 3, 1]), + iv: { + bytes: new Uint32Array([0, 0, 0, 1]) + } + } + }, + this.noop, + (error, segmentData) => { + assert.notOk(error, 'there are no errors'); + assert.ok(segmentData.bytes, 'decrypted bytes in segment'); + assert.ok(segmentData.key.bytes, 'key bytes in segment'); + assert.equal( + segmentData.key.bytes.buffer.byteLength, + 16, + 'key bytes are readable' + ); + + // verify stats + assert.equal(segmentData.stats.bytesReceived, 8, '8 bytes'); + done(); + }); + + assert.equal(this.requests.length, 1, 'there is one request'); + const segmentReq = this.requests.shift(); + + assert.equal(segmentReq.uri, '0-test.ts', 'the second request is for a segment'); + + segmentReq.response = new Uint8Array(8).buffer; + segmentReq.respond(200, null, ''); + + // Allow the decrypter to decrypt + this.clock.tick(100); +}); + QUnit.test('key 404 calls back with error', function(assert) { const done = assert.async(); let segmentReq; diff --git a/test/segment-loader.test.js b/test/segment-loader.test.js index 4f14f1abf..fadcae706 100644 --- a/test/segment-loader.test.js +++ b/test/segment-loader.test.js @@ -229,6 +229,146 @@ QUnit.module('SegmentLoader', function(hooks) { 'segment end time not shifted by mp4 start time'); }); + QUnit.test('segmentKey will cache new encrypted keys with cacheEncryptionKeys true', function(assert) { + const newLoader = new SegmentLoader(LoaderCommonSettings.call(this, { + loaderType: 'main', + segmentMetadataTrack: this.segmentMetadataTrack, + cacheEncryptionKeys: true + }), {}); + + newLoader.playlist(playlistWithDuration(10), { isEncrypted: true }); + newLoader.mimeType(this.mimeType); + newLoader.load(); + this.clock.tick(1); + + assert.strictEqual( + Object.keys(newLoader.keyCache_).length, + 0, + 'no keys have been cached' + ); + + const result = newLoader.segmentKey({ + resolvedUri: 'key.php', + bytes: new Uint32Array([1, 2, 3, 4]) + }); + + assert.deepEqual( + result, + { resolvedUri: 'key.php' }, + 'gets by default' + ); + + newLoader.segmentKey( + { + resolvedUri: 'key.php', + bytes: new Uint32Array([1, 2, 3, 4]) + }, + true + ); + + assert.deepEqual( + newLoader.keyCache_['key.php'].bytes, + new Uint32Array([1, 2, 3, 4]), + 'key has been cached' + ); + }); + + QUnit.test('segmentKey will not cache encrypted keys with cacheEncryptionKeys false', function(assert) { + const newLoader = new SegmentLoader(LoaderCommonSettings.call(this, { + loaderType: 'main', + segmentMetadataTrack: this.segmentMetadataTrack, + cacheEncryptionKeys: false + }), {}); + + newLoader.playlist(playlistWithDuration(10), { isEncrypted: true }); + newLoader.mimeType(this.mimeType); + newLoader.load(); + this.clock.tick(1); + + assert.strictEqual( + Object.keys(newLoader.keyCache_).length, + 0, + 'no keys have been cached' + ); + + newLoader.segmentKey( + { + resolvedUri: 'key.php', + bytes: new Uint32Array([1, 2, 3, 4]) + }, + // set = true + true + ); + + assert.strictEqual( + Object.keys(newLoader.keyCache_).length, + 0, + 'no keys have been cached since cacheEncryptionKeys is false' + ); + }); + + QUnit.test('new segment requests will use cached keys', function(assert) { + const done = assert.async(); + const newLoader = new SegmentLoader(LoaderCommonSettings.call(this, { + loaderType: 'main', + segmentMetadataTrack: this.segmentMetadataTrack, + cacheEncryptionKeys: true + }), {}); + + newLoader.playlist(playlistWithDuration(20, { isEncrypted: true })); + // make the keys the same + newLoader.playlist_.segments[1].key = + videojs.mergeOptions({}, newLoader.playlist_.segments[0].key); + // give 2nd key an iv + newLoader.playlist_.segments[1].key.iv = new Uint32Array([0, 1, 2, 3]); + + newLoader.mimeType(this.mimeType); + newLoader.load(); + this.clock.tick(1); + + assert.strictEqual(this.requests.length, 2, 'two requests'); + assert.strictEqual(this.requests[0].uri, '0-key.php', 'key request'); + assert.strictEqual(this.requests[1].uri, '0.ts', 'segment request'); + + // key response + standardXHRResponse(this.requests.shift(), new Uint32Array([1, 1, 1, 1])); + this.clock.tick(1); + // segment + standardXHRResponse(this.requests.shift(), new Uint32Array([1, 5, 0, 1])); + this.clock.tick(1); + + // As the Decrypter is in a web worker, the last function in SegmentLoader is + // the easiest way to listen for the decrypted response + const origHandleSegment = newLoader.handleSegment_.bind(newLoader); + + newLoader.handleSegment_ = () => { + origHandleSegment(); + this.updateend(); + assert.deepEqual( + newLoader.keyCache_['0-key.php'], + { + resolvedUri: '0-key.php', + bytes: new Uint32Array([16777216, 16777216, 16777216, 16777216]) + }, + 'previous key was cached'); + + this.clock.tick(1); + assert.deepEqual( + newLoader.pendingSegment_.segment.key, + { + resolvedUri: '0-key.php', + uri: '0-key.php', + iv: new Uint32Array([0, 1, 2, 3]) + }, + 'used cached key for request and own initialization vector' + ); + + assert.strictEqual(this.requests.length, 1, 'one request'); + assert.strictEqual(this.requests[0].uri, '1.ts', 'only segment request'); + done(); + }; + }); + QUnit.test('triggers syncinfoupdate before attempting a resync', async function(assert) { await setupMediaSource(loader.mediaSource_, loader.sourceUpdater_); diff --git a/test/test-helpers.js b/test/test-helpers.js index fb39f197c..d94e5fa10 100644 --- a/test/test-helpers.js +++ b/test/test-helpers.js @@ -388,21 +388,25 @@ export const standardXHRResponse = function(request, data) { contentType = 'video/MP2T'; } else if (/\.mpd/.test(request.url)) { contentType = 'application/dash+xml'; + } else if (request.responseType === 'arraybuffer') { + contentType = 'binary/octet-stream'; } if (!data) { data = testDataManifests[manifestName]; } + const isTypedBuffer = data instanceof Uint8Array || data instanceof Uint32Array; + request.response = // if segment data was passed, use that, otherwise use a placeholder - data instanceof Uint8Array ? data.buffer : new Uint8Array(1024).buffer; + isTypedBuffer ? data.buffer : new Uint8Array(1024).buffer; // `response` will get the full value after the request finishes request.respond( 200, { 'Content-Type': contentType }, - data instanceof Uint8Array ? '' : data + isTypedBuffer ? '' : data ); }; diff --git a/test/videojs-http-streaming.test.js b/test/videojs-http-streaming.test.js index a96f6804d..c7586c580 100644 --- a/test/videojs-http-streaming.test.js +++ b/test/videojs-http-streaming.test.js @@ -1618,16 +1618,16 @@ QUnit.test('sets seekable and duration for live playlists', async function(asser // since the safe live end will be 3 target durations back, in order for there to be a // positive seekable end, there should be at least 4 segments this.requests.shift().respond(200, null, ` - #EXTM3U - #EXT-X-TARGETDURATION:5 - #EXTINF:5 - 0.ts - #EXTINF:5 - 1.ts - #EXTINF:5 - 2.ts - #EXTINF:5 - 3.ts + #EXTM3U + #EXT-X-TARGETDURATION:5 + #EXTINF:5 + 0.ts + #EXTINF:5 + 1.ts + #EXTINF:5 + 2.ts + #EXTINF:5 + 3.ts `); assert.equal(this.player.vhs.seekable().length, 1, 'set one seekable range'); @@ -2437,6 +2437,137 @@ QUnit.test('keys are resolved relative to their containing playlist', function(a 'resolves multiple relative paths'); }); +QUnit.test('keys are not requested when cached key available, cacheEncryptionKeys:true', function(assert) { + const done = assert.async(); + + this.player.src({ + src: 'video/media-encrypted.m3u8', + type: 'application/vnd.apple.mpegurl', + cacheEncryptionKeys: true + }); + this.clock.tick(1); + + openMediaSource(this.player, this.clock); + this.requests.shift().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-TARGETDURATION:15\n' + + '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php",IV=0x00000000000000000000000000000000\n' + + '#EXTINF:2.833,\n' + + 'http://media.example.com/fileSequence1.ts\n' + + '#EXTINF:2.833,\n' + + 'http://media.example.com/fileSequence2.ts\n' + + '#EXT-X-ENDLIST\n'); + this.clock.tick(1); + + assert.equal(this.requests.length, 2, 'requested a key'); + assert.equal( + this.requests[0].url, + absoluteUrl('video/keys/key.php'), + 'requested the key' + ); + assert.equal( + this.requests[1].url, + 'http://media.example.com/fileSequence1.ts', + 'requested the segment' + ); + + // key response + this.standardXHRResponse(this.requests.shift(), new Uint32Array([1, 2, 3, 4])); + // segment response + this.standardXHRResponse(this.requests.shift()); + this.clock.tick(1); + + // As the Decrypter is in a web worker, the last function in SegmentLoader is + // the easiest way to listen for the decrypted response + const mainSegmentLoader = this.player.vhs.masterPlaylistController_.mainSegmentLoader_; + const origHandleSegment = mainSegmentLoader.handleSegment_; + + mainSegmentLoader.handleSegment_ = () => { + origHandleSegment.call(mainSegmentLoader); + + this.player.tech_.hls.mediaSource.sourceBuffers[0].trigger('updateend'); + this.clock.tick(1); + + assert.equal(this.requests.length, 1, 'requested a segment, not a key'); + assert.equal( + this.requests[0].url, + absoluteUrl('http://media.example.com/fileSequence2.ts'), + 'requested the segment only' + ); + + mainSegmentLoader.handleSegment_ = origHandleSegment; + done(); + }; +}); + +QUnit.test('keys are requested per segment, cacheEncryptionKeys:false', function(assert) { + const done = assert.async(); + + this.player.src({ + src: 'video/media-encrypted.m3u8', + type: 'application/vnd.apple.mpegurl', + cacheEncryptionKeys: false + }); + this.clock.tick(1); + + openMediaSource(this.player, this.clock); + this.requests.shift().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-TARGETDURATION:15\n' + + '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php",IV=0x00000000000000000000000000000000\n' + + '#EXTINF:2.833,\n' + + 'http://media.example.com/fileSequence1.ts\n' + + '#EXTINF:2.833,\n' + + 'http://media.example.com/fileSequence2.ts\n' + + '#EXT-X-ENDLIST\n'); + this.clock.tick(1); + + assert.equal(this.requests.length, 2, 'requested a key and segment'); + assert.equal( + this.requests[0].url, + absoluteUrl('video/keys/key.php'), + 'requested the key' + ); + assert.equal( + this.requests[1].url, + 'http://media.example.com/fileSequence1.ts', + 'requested the segment' + ); + + // key response + this.standardXHRResponse(this.requests.shift(), new Uint32Array([1, 2, 3, 4])); + // segment response + this.standardXHRResponse(this.requests.shift()); + this.clock.tick(1); + + // As the Decrypter is in a web worker, the last function in SegmentLoader is + // the easiest way to listen for the decrypted response + const mainSegmentLoader = this.player.vhs.masterPlaylistController_.mainSegmentLoader_; + const origHandleSegment = mainSegmentLoader.handleSegment_; + + mainSegmentLoader.handleSegment_ = () => { + origHandleSegment.call(mainSegmentLoader); + + this.player.tech_.hls.mediaSource.sourceBuffers[0].trigger('updateend'); + this.clock.tick(1); + + assert.equal(this.requests.length, 2, 'requested a segment and a key'); + assert.equal( + this.requests[0].url, + absoluteUrl('video/keys/key.php'), + 'requested the key again' + ); + assert.equal( + this.requests[1].url, + absoluteUrl('http://media.example.com/fileSequence2.ts'), + 'requested the segment' + ); + + mainSegmentLoader.handleSegment_ = origHandleSegment; + done(); + }; +}); + QUnit.test('seeking should abort an outstanding key request and create a new one', function(assert) { this.player.src({