diff --git a/demo/common/assets.js b/demo/common/assets.js index bccca179d6b..a1d97e16713 100644 --- a/demo/common/assets.js +++ b/demo/common/assets.js @@ -346,6 +346,22 @@ shakaAssets.testAssets = [ .addFeature(shakaAssets.Feature.SURROUND) .addFeature(shakaAssets.Feature.OFFLINE) .addLicenseServer('com.widevine.alpha', 'https://cwip-shaka-proxy.appspot.com/no_auth'), + new ShakaDemoAssetInfo( + /* name= */ 'Sintel (HLS, TS, AES-128 key rotation)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png', + /* manifestUri= */ 'https://storage.googleapis.com/shaka-demo-assets/sintel-ts-aes-key-rotation/master.m3u8', + /* source= */ shakaAssets.Source.SHAKA) + .addFeature(shakaAssets.Feature.HLS) + .addFeature(shakaAssets.Feature.MP2TS) + .addFeature(shakaAssets.Feature.OFFLINE), + new ShakaDemoAssetInfo( + /* name= */ 'Sintel (HLS, FMP4, AES-128)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png', + /* manifestUri= */ 'https://storage.googleapis.com/shaka-demo-assets/sintel-fmp4-aes/master.m3u8', + /* source= */ shakaAssets.Source.SHAKA) + .addFeature(shakaAssets.Feature.HLS) + .addFeature(shakaAssets.Feature.MP4) + .addFeature(shakaAssets.Feature.OFFLINE), new ShakaDemoAssetInfo( /* name= */ 'Sintel 4k (multicodec)', /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png', @@ -904,6 +920,15 @@ shakaAssets.testAssets = [ .addFeature(shakaAssets.Feature.HLS) .addFeature(shakaAssets.Feature.MP2TS) .addFeature(shakaAssets.Feature.OFFLINE), + new ShakaDemoAssetInfo( + /* name= */ 'Art of Motion (HLS, TS, AES-128)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/art_of_motion.png', + /* manifestUri= */ 'https://bitmovin-a.akamaihd.net/content/art-of-motion_drm/m3u8s/11331.m3u8', + /* source= */ shakaAssets.Source.BITCODIN) + .addFeature(shakaAssets.Feature.HIGH_DEFINITION) + .addFeature(shakaAssets.Feature.HLS) + .addFeature(shakaAssets.Feature.MP2TS) + .addFeature(shakaAssets.Feature.OFFLINE), new ShakaDemoAssetInfo( /* name= */ 'Sintel (HLS, TS, 4k)', /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png', diff --git a/externs/shaka/manifest.js b/externs/shaka/manifest.js index 4bf22239b61..759990e5d80 100644 --- a/externs/shaka/manifest.js +++ b/externs/shaka/manifest.js @@ -244,6 +244,39 @@ shaka.extern.Variant; shaka.extern.CreateSegmentIndexFunction; +/** + * @typedef {{ + * method: string, + * cryptoKey: (webCrypto.CryptoKey|undefined), + * fetchKey: (shaka.extern.CreateSegmentIndexFunction|undefined), + * iv: (!Uint8Array|undefined), + * firstMediaSequenceNumber: number + * }} + * + * @description + * AES-128 key and iv info from the HLS manifest. + * + * @property {string} method + * The key method defined in the HLS manifest. + * @property {webCrypto.CryptoKey|undefined} cryptoKey + * Web crypto key object of the AES-128 CBC key. If unset, the "fetchKey" + * property should be provided. + * @property {shaka.extern.CreateSegmentIndexFunction|undefined} fetchKey + * A function that fetches the key, provided if cryptoKey is unset. + * Should update the HlsAes128Key object in-place. + * Used if the key should be fetched later, when the segment is downloaded. + * @property {(!Uint8Array|undefined)} iv + * The IV in the HLS manifest, if defined. See HLS RFC 8216 Section 5.2 for + * handling undefined IV. + * @property {number} firstMediaSequenceNumber + * The starting Media Sequence Number of the playlist, used when IV is + * undefined. + * + * @exportDoc + */ +shaka.extern.HlsAes128Key; + + /** * @typedef {{ * id: number, diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 787eaeb2f0a..49bd1a8a41e 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -37,6 +37,7 @@ goog.require('shaka.util.OperationManager'); goog.require('shaka.util.Pssh'); goog.require('shaka.util.Timer'); goog.require('shaka.util.Platform'); +goog.require('shaka.util.Uint8ArrayUtils'); goog.require('shaka.util.XmlUtils'); goog.requireType('shaka.hls.Segment'); @@ -1644,34 +1645,30 @@ shaka.hls.HlsParser = class { if (method != 'NONE') { encrypted = true; - // We do not support AES-128 encryption with HLS yet. So, do not create - // StreamInfo for the playlist encrypted with AES-128. - // TODO: Remove the error message once we add support for AES-128. if (method == 'AES-128') { - shaka.log.warning('Unsupported HLS Encryption', method); + // These keys are handled separately. this.aesEncrypted_ = true; - return null; - } - - const keyFormat = drmTag.getRequiredAttrValue('KEYFORMAT'); - const drmParser = - shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_[keyFormat]; - - const drmInfo = drmParser ? drmParser(drmTag, mimeType) : null; - if (drmInfo) { - if (drmInfo.keyIds) { - for (const keyId of drmInfo.keyIds) { - keyIds.add(keyId); + } else { + const keyFormat = drmTag.getRequiredAttrValue('KEYFORMAT'); + const drmParser = + shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_[keyFormat]; + + const drmInfo = drmParser ? drmParser(drmTag, mimeType) : null; + if (drmInfo) { + if (drmInfo.keyIds) { + for (const keyId of drmInfo.keyIds) { + keyIds.add(keyId); + } } + drmInfos.push(drmInfo); + } else { + shaka.log.warning('Unsupported HLS KEYFORMAT', keyFormat); } - drmInfos.push(drmInfo); - } else { - shaka.log.warning('Unsupported HLS KEYFORMAT', keyFormat); } } } - if (encrypted && !drmInfos.length) { + if (encrypted && !drmInfos.length && !this.aesEncrypted_) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, @@ -1688,9 +1685,8 @@ shaka.hls.HlsParser = class { let segments; try { - segments = this.createSegments_(verbatimMediaPlaylistUri, - playlist, type, mimeType, mediaSequenceToStartTime, mediaVariables, - codecs); + segments = this.createSegments_(verbatimMediaPlaylistUri, playlist, type, + mimeType, mediaSequenceToStartTime, mediaVariables, codecs); } catch (error) { if (error.code == shaka.util.Error.Code.HLS_INTERNAL_SKIP_STREAM) { shaka.log.alwaysWarn('Skipping unsupported HLS stream', @@ -1765,6 +1761,82 @@ shaka.hls.HlsParser = class { } + /** + * @param {!shaka.hls.Tag} drmTag + * @param {!shaka.hls.Playlist} playlist + * @return {!shaka.extern.HlsAes128Key} + * @private + */ + parseAES128DrmTag_(drmTag, playlist) { + // Check if the Web Crypto API is available. + if (!window.crypto || !window.crypto.subtle) { + shaka.log.alwaysWarn('Web Crypto API is not available to decrypt ' + + 'AES-128. (Web Crypto only exists in secure origins like https)'); + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.NO_WEB_CRYPTO_API); + } + + // HLS RFC 8216 Section 5.2: + // An EXT-X-KEY tag with a KEYFORMAT of "identity" that does not have an IV + // attribute indicates that the Media Sequence Number is to be used as the + // IV when decrypting a Media Segment, by putting its big-endian binary + // representation into a 16-octet (128-bit) buffer and padding (on the left) + // with zeros. + let firstMediaSequenceNumber = 0; + let iv; + const ivHex = drmTag.getAttributeValue('IV', ''); + if (!ivHex) { + // Media Sequence Number will be used as IV. + firstMediaSequenceNumber = shaka.hls.Utils.getFirstTagWithNameAsNumber( + playlist.tags, 'EXT-X-MEDIA-SEQUENCE', 0); + } else { + // Exclude 0x at the start of string. + iv = shaka.util.Uint8ArrayUtils.fromHex(ivHex.substr(2)); + if (iv.byteLength != 16) { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.HLS_AES_128_INVALID_KEY_OR_IV_LENGTH, + 'IV'); + } + } + + const keyUri = shaka.hls.Utils.constructAbsoluteUri( + playlist.absoluteUri, drmTag.getRequiredAttrValue('URI')); + + const requestType = shaka.net.NetworkingEngine.RequestType.KEY; + const request = shaka.net.NetworkingEngine.makeRequest( + [keyUri], this.config_.retryParameters); + + const keyInfo = /* @type {!shaka.extern.HlsAes128Key} */ ({ + method: 'AES-128', iv, firstMediaSequenceNumber, + }); + + // Don't download the key object until the segment is parsed, to avoid a + // startup delay for long manifests with lots of keys. + keyInfo.fetchKey = async () => { + const keyResponse = await this.makeNetworkRequest_(request, requestType); + + // keyResponse.status is undefined when URI is "data:text/plain;base64," + if (!keyResponse.data || keyResponse.data.byteLength != 16) { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.HLS_AES_128_INVALID_KEY_OR_IV_LENGTH, + 'KEY'); + } + + keyInfo.cryptoKey = await window.crypto.subtle.importKey( + 'raw', keyResponse.data, 'AES-CBC', true, ['decrypt']); + keyInfo.fetchKey = undefined; // No longer needed. + }; + + return keyInfo; + } + + /** * @param {!shaka.hls.Playlist} playlist * @private @@ -1939,12 +2011,13 @@ shaka.hls.HlsParser = class { * @param {!Map.} variables * @param {string} absoluteMediaPlaylistUri * @param {string} type + * @param {shaka.extern.HlsAes128Key=} hlsAes128Key * @return {shaka.media.SegmentReference} * @private */ createSegmentReference_( initSegmentReference, previousReference, hlsSegment, startTime, - variables, absoluteMediaPlaylistUri, type) { + variables, absoluteMediaPlaylistUri, type, hlsAes128Key) { const tags = hlsSegment.tags; const absoluteSegmentUri = this.variableSubstitution_( hlsSegment.absoluteUri, variables); @@ -2039,6 +2112,8 @@ shaka.hls.HlsParser = class { partialStatus = shaka.media.SegmentReference.Status.MISSING; } + // We do not set the AES-128 key information for partial segments, as we + // do not support AES-128 and low-latency at the same time. const partial = new shaka.media.SegmentReference( pStartTime, pEndTime, @@ -2120,6 +2195,7 @@ shaka.hls.HlsParser = class { tileDuration, syncTime, status, + hlsAes128Key, ); } @@ -2186,6 +2262,9 @@ shaka.hls.HlsParser = class { /** @type {shaka.media.InitSegmentReference} */ let initSegmentRef; + /** @type {shaka.extern.HlsAes128Key|undefined} */ + let hlsAes128Key = undefined; + // We may need to look at the media itself to determine a segment start // time. const mediaSequenceNumber = shaka.hls.Utils.getFirstTagWithNameAsNumber( @@ -2214,6 +2293,15 @@ shaka.hls.HlsParser = class { (i == 0) ? firstStartTime : previousReference.endTime; position = mediaSequenceNumber + skippedSegments + i; + // Apply new AES-128 tags as you see them, keeping a running total. + for (const drmTag of item.tags) { + if (drmTag.name == 'EXT-X-KEY' && + drmTag.getRequiredAttrValue('METHOD') == 'AES-128') { + // eslint-disable-next-line no-await-in-loop + hlsAes128Key = this.parseAES128DrmTag_(drmTag, playlist); + } + } + mediaSequenceToStartTime.set(position, startTime); initSegmentRef = this.getInitSegmentReference_(playlist.absoluteUri, @@ -2238,7 +2326,8 @@ shaka.hls.HlsParser = class { startTime, variables, playlist.absoluteUri, - type); + type, + hlsAes128Key); previousReference = reference; if (reference) { diff --git a/lib/media/segment_index.js b/lib/media/segment_index.js index b9fe48fb1e0..ee51b633747 100644 --- a/lib/media/segment_index.js +++ b/lib/media/segment_index.js @@ -529,6 +529,14 @@ shaka.media.SegmentIterator = class { this.currentPartialPosition_ = partialSegmentIndex; } + /** + * @return {number} + * @export + */ + currentPosition() { + return this.currentPosition_; + } + /** * @return {shaka.media.SegmentReference} * @export diff --git a/lib/media/segment_reference.js b/lib/media/segment_reference.js index f504ab5237c..eaf0d6824fb 100644 --- a/lib/media/segment_reference.js +++ b/lib/media/segment_reference.js @@ -166,12 +166,15 @@ shaka.media.SegmentReference = class { * @param {shaka.media.SegmentReference.Status=} status * The segment status is used to indicate that a segment does not exist or is * not available. + * @param {shaka.extern.HlsAes128Key=} hlsAes128Key + * The segment's AES-128-CBC full segment encryption key and iv. */ constructor( startTime, endTime, uris, startByte, endByte, initSegmentReference, timestampOffset, appendWindowStart, appendWindowEnd, partialReferences = [], tilesLayout = '', tileDuration = null, - syncTime = null, status = shaka.media.SegmentReference.Status.AVAILABLE) { + syncTime = null, status = shaka.media.SegmentReference.Status.AVAILABLE, + hlsAes128Key) { // A preload hinted Partial Segment has the same startTime and endTime. goog.asserts.assert(startTime <= endTime, 'startTime must be less than or equal to endTime'); @@ -233,6 +236,9 @@ shaka.media.SegmentReference = class { /** @type {shaka.media.SegmentReference.Status} */ this.status = status; + + /** @type {shaka.extern.HlsAes128Key|undefined} */ + this.hlsAes128Key = hlsAes128Key; } /** diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index a5670992463..b54dc7bf6ae 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -1233,7 +1233,10 @@ shaka.media.StreamingEngine = class { stream.mimeType == 'audio/mp4'; const isReadableStreamSupported = window.ReadableStream; // Enable MP4 low latency streaming with ReadableStream chunked data. - if (this.config_.lowLatencyMode && isReadableStreamSupported && isMP4) { + // Disabled when AES-128 is present, as we cannot decrypt part of a + // segment. + if (this.config_.lowLatencyMode && isReadableStreamSupported && isMP4 && + !reference.hlsAes128Key) { let remaining = new Uint8Array(0); const streamDataCallback = async (data) => { this.destroyer_.ensureNotDestroyed(); @@ -1273,11 +1276,15 @@ shaka.media.StreamingEngine = class { 'ReadableStream is not supported by the browser.'); } const fetchSegment = this.fetch_(mediaState, reference); - const result = await fetchSegment; + let result = await fetchSegment; this.destroyer_.ensureNotDestroyed(); if (this.fatalError_) { return; } + if (reference.hlsAes128Key && iter) { + result = await this.aes128Decrypt_(result, reference, iter); + } + this.destroyer_.ensureNotDestroyed(); // If the text stream gets switched between fetch_() and append_(), the // new text parser is initialized, but the new init segment is not @@ -1368,6 +1375,34 @@ shaka.media.StreamingEngine = class { } } + /** + * @param {!BufferSource} rawResult + * @param {!shaka.media.SegmentReference} reference + * @param {!shaka.media.SegmentIterator} iter + * @return {!Promise.} finalResult + * @private + */ + async aes128Decrypt_(rawResult, reference, iter) { + const key = reference.hlsAes128Key; + if (!key.cryptoKey) { + goog.asserts.assert(key.fetchKey, 'If AES-128 cryptoKey was not ' + + 'preloaded, fetchKey function should be provided'); + await key.fetchKey(); + goog.asserts.assert(key.cryptoKey, 'AES-128 cryptoKey should now be set'); + } + let iv = key.iv; + if (!iv) { + iv = shaka.util.BufferUtils.toUint8(new ArrayBuffer(16)); + let sequence = key.firstMediaSequenceNumber + iter.currentPosition(); + for (let i = iv.byteLength - 1; i >= 0; i--) { + iv[i] = sequence & 0xff; + sequence >>= 8; + } + } + return window.crypto.subtle.decrypt( + {name: 'AES-CBC', iv}, key.cryptoKey, rawResult); + } + /** * Clear per-stream error states and retry any failed streams. diff --git a/lib/net/networking_engine.js b/lib/net/networking_engine.js index 17bf470acc8..cefa6c8a26a 100644 --- a/lib/net/networking_engine.js +++ b/lib/net/networking_engine.js @@ -749,6 +749,7 @@ shaka.net.NetworkingEngine.RequestType = { 'APP': 3, 'TIMING': 4, 'SERVER_CERTIFICATE': 5, + 'KEY': 6, }; diff --git a/lib/util/error.js b/lib/util/error.js index 529d740cf88..0a9d8ccf4f8 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -699,6 +699,17 @@ shaka.util.Error.Code = { */ 'HLS_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED': 4041, + /** + * Web Crypto API is not available (to decrypt AES-128 streams). Web Crypto + * only exists in secure origins like https. + */ + 'NO_WEB_CRYPTO_API': 4042, + + /** + * AES-128 encryption key or iv length should be 16 bytes. + */ + 'HLS_AES_128_INVALID_KEY_OR_IV_LENGTH': 4043, + // RETIRED: 'INCONSISTENT_BUFFER_STATE': 5000, // RETIRED: 'INVALID_SEGMENT_INDEX': 5001, diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index 2c613823f25..dc3f5fc4cb7 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -33,6 +33,8 @@ describe('HlsParser', () => { let segmentData; /** @type {!Uint8Array} */ let selfInitializingSegmentData; + /** @type {!Uint8Array} */ + let aes128Key; afterEach(() => { shaka.log.alwaysWarn = originalAlwaysWarn; @@ -75,6 +77,11 @@ describe('HlsParser', () => { selfInitializingSegmentData = shaka.util.Uint8ArrayUtils.concat(initSegmentData, segmentData); + aes128Key = new Uint8Array([ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + ]); + fakeNetEngine = new shaka.test.FakeNetworkingEngine(); config = shaka.util.PlayerConfiguration.createDefault().manifest; @@ -2432,7 +2439,7 @@ describe('HlsParser', () => { expect(initSegments[1].getUris()[0]).toBe('test:/init2.mp4'); }); - it('drops variants encrypted with AES-128', async () => { + it('parses variants encrypted with AES-128', async () => { const master = [ '#EXTM3U\n', '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1,mp4a",', @@ -2441,10 +2448,15 @@ describe('HlsParser', () => { '#EXT-X-STREAM-INF:BANDWIDTH=300,CODECS="avc1,mp4a",', 'RESOLUTION=960x540,FRAME-RATE=120,AUDIO="aud2"\n', 'video2\n', + '#EXT-X-STREAM-INF:BANDWIDTH=300,CODECS="avc1,mp4a",', + 'RESOLUTION=960x540,FRAME-RATE=120,AUDIO="aud3"\n', + 'video3\n', '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud1",LANGUAGE="eng",', 'URI="audio"\n', '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud2",LANGUAGE="fr",', 'URI="audio2"\n', + '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud3",LANGUAGE="de",', + 'URI="audio3"\n', ].join(''); const media = [ @@ -2456,17 +2468,26 @@ describe('HlsParser', () => { 'main.mp4', ].join(''); - const mediaWithAesEncryption = [ + const mediaWithMp4AesEncryption = [ '#EXTM3U\n', '#EXT-X-PLAYLIST-TYPE:VOD\n', '#EXT-X-KEY:METHOD=AES-128,', - 'URI="800k.key\n', + 'URI="800k.key"\n', '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', '#EXTINF:5,\n', '#EXT-X-BYTERANGE:121090@616\n', 'main.mp4', ].join(''); + const mediaWithTSAesEncryption = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-KEY:METHOD=AES-128,', + 'URI="800k.key"\n', + '#EXTINF:5,\n', + 'main.ts', + ].join(''); + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { manifest.anyTimeline(); manifest.addPartialVariant((variant) => { @@ -2478,6 +2499,24 @@ describe('HlsParser', () => { stream.language = 'en'; }); }); + manifest.addPartialVariant((variant) => { + variant.bandwidth = 300; + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.size(960, 540); + }); + variant.addPartialStream(ContentType.AUDIO, (stream) => { + stream.language = 'fr'; + }); + }); + manifest.addPartialVariant((variant) => { + variant.bandwidth = 300; + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.size(960, 540); + }); + variant.addPartialStream(ContentType.AUDIO, (stream) => { + stream.language = 'de'; + }); + }); manifest.sequenceMode = true; }); @@ -2485,12 +2524,15 @@ describe('HlsParser', () => { .setResponseText('test:/master', master) .setResponseText('test:/audio', media) .setResponseText('test:/audio2', media) + .setResponseText('test:/audio3', media) .setResponseText('test:/video', media) - .setResponseText('test:/video2', mediaWithAesEncryption) + .setResponseText('test:/video2', mediaWithMp4AesEncryption) + .setResponseText('test:/video3', mediaWithTSAesEncryption) .setResponseText('test:/main.vtt', vttText) .setResponseValue('test:/init.mp4', initSegmentData) .setResponseValue('test:/main.mp4', segmentData) .setResponseValue('test:/main.test', segmentData) + .setResponseValue('test:/800k.key', aes128Key) .setResponseValue('test:/selfInit.mp4', selfInitializingSegmentData); const actual = await parser.start('test:/master', playerInterface); @@ -2674,7 +2716,9 @@ describe('HlsParser', () => { .setResponseText('test:/video', media) .setResponseValue('test:/main.exe', segmentData) .setResponseValue('test:/init.mp4', initSegmentData) - .setResponseValue('test:/main.mp4', segmentData); + .setResponseValue('test:/main.mp4', segmentData) + .setResponseValue('data:text/plain;base64,AAECAwQFBgcICQoLDA0ODw==', + aes128Key); await expectAsync(parser.start('test:/master', playerInterface)) .toBeRejectedWith(Util.jasmineError(error)); @@ -2708,34 +2752,6 @@ describe('HlsParser', () => { await verifyError(master, media, error); }); - it('if all variants are encrypted with AES-128', async () => { - const master = [ - '#EXTM3U\n', - '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1",', - 'RESOLUTION=960x540,FRAME-RATE=60\n', - 'video\n', - ].join(''); - - const media = [ - '#EXTM3U\n', - '#EXT-X-TARGETDURATION:6\n', - '#EXT-X-PLAYLIST-TYPE:VOD\n', - '#EXT-X-KEY:METHOD=AES-128,', - 'URI="data:text/plain;base64\n', - '#EXT-X-MAP:URI="init.mp4"\n', - '#EXTINF:5,\n', - '#EXT-X-BYTERANGE:121090@616\n', - 'main.mp4', - ].join(''); - - const error = new shaka.util.Error( - shaka.util.Error.Severity.CRITICAL, - shaka.util.Error.Category.MANIFEST, - Code.HLS_AES_128_ENCRYPTION_NOT_SUPPORTED); - - await verifyError(master, media, error); - }); - describe('if required attributes are missing', () => { /** * @param {string} master