From 172c9f834ab6575cf9cdb2f825abd9961b9ad7fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Velad=20Galv=C3=A1n?= Date: Tue, 8 Nov 2022 23:43:42 +0100 Subject: [PATCH] feat(HLS): Add support for EXT-X-SESSION-KEY tag (#4655) Closes #917 --- lib/hls/hls_parser.js | 57 +++++++++++++- test/hls/hls_parser_unit.js | 153 ++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 4 deletions(-) diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 52b31c4b34..93696f906c 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -670,6 +670,9 @@ shaka.hls.HlsParser = class { /** @type {!Array.} */ const imageTags = Utils.filterTagsByName( playlist.tags, 'EXT-X-IMAGE-STREAM-INF'); + /** @type {!Array.} */ + const sessionKeyTags = Utils.filterTagsByName( + playlist.tags, 'EXT-X-SESSION-KEY'); this.parseCodecs_(variantTags); @@ -702,7 +705,7 @@ shaka.hls.HlsParser = class { // start time from audio/video streams and reuse for text streams. this.createStreamInfosFromMediaTags_(mediaTags); this.parseClosedCaptions_(mediaTags); - variants = this.createVariantsForTags_(variantTags); + variants = this.createVariantsForTags_(variantTags, sessionKeyTags); textStreams = this.parseTexts_(mediaTags); imageStreams = await this.parseImages_(imageTags); } @@ -981,10 +984,43 @@ shaka.hls.HlsParser = class { /** * @param {!Array.} tags Variant tags from the playlist. + * @param {!Array.} sessionKeyTags EXT-X-SESSION-KEY tags + * from the playlist. * @return {!Array.} * @private */ - createVariantsForTags_(tags) { + createVariantsForTags_(tags, sessionKeyTags) { + // EXT-X-SESSION-KEY processing + const drmInfos = []; + const keyIds = new Set(); + if (sessionKeyTags.length > 0) { + for (const drmTag of sessionKeyTags) { + const method = drmTag.getRequiredAttrValue('METHOD'); + if (method != 'NONE' && method != 'AES-128') { + // According to the HLS spec, KEYFORMAT is optional and implicitly + // defaults to "identity". + // https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-11#section-4.4.4.4 + const keyFormat = + drmTag.getAttributeValue('KEYFORMAT') || 'identity'; + 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); + } + } + } + } + // Create variants for each variant tag. const allVariants = tags.map((tag) => { const frameRate = tag.getAttributeValue('FRAME-RATE'); @@ -1009,7 +1045,9 @@ shaka.hls.HlsParser = class { width, height, frameRate, - videoRange); + videoRange, + drmInfos, + keyIds); }); let variants = allVariants.reduce(shaka.util.Functional.collapseArrays, []); // Filter out null variants. @@ -1251,11 +1289,14 @@ shaka.hls.HlsParser = class { * @param {?string} height * @param {?string} frameRate * @param {?string} videoRange + * @param {!Array.} drmInfos + * @param {!Set.} keyIds * @return {!Array.} * @private */ createVariants_( - audioInfos, videoInfos, bandwidth, width, height, frameRate, videoRange) { + audioInfos, videoInfos, bandwidth, width, height, frameRate, videoRange, + drmInfos, keyIds) { const ContentType = shaka.util.ManifestParserUtils.ContentType; const DrmEngine = shaka.media.DrmEngine; @@ -1281,7 +1322,15 @@ shaka.hls.HlsParser = class { for (const audioInfo of audioInfos) { for (const videoInfo of videoInfos) { const audioStream = audioInfo ? audioInfo.stream : null; + if (audioStream) { + audioStream.drmInfos = drmInfos; + audioStream.keyIds = keyIds; + } const videoStream = videoInfo ? videoInfo.stream : null; + if (videoStream) { + videoStream.drmInfos = drmInfos; + videoStream.keyIds = keyIds; + } const audioDrmInfos = audioInfo ? audioInfo.stream.drmInfos : null; const videoDrmInfos = videoInfo ? videoInfo.stream.drmInfos : null; const videoStreamUri = diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index 72dade6ca6..e4c2295dbd 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -2918,6 +2918,159 @@ describe('HlsParser', () => { expect(newDrmInfoSpy).toHaveBeenCalled(); }); + describe('constructs DrmInfo with EXT-X-SESSION-KEY', () => { + it('for Widevine', async () => { + const initDataBase64 = + 'dGhpcyBpbml0IGRhdGEgY29udGFpbnMgaGlkZGVuIHNlY3JldHMhISE='; + + const keyId = 'abc123'; + + const master = [ + '#EXTM3U\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1",', + 'RESOLUTION=960x540,FRAME-RATE=60\n', + 'video\n', + '#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES-CTR,', + 'KEYID=0X' + keyId + ',', + 'KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",', + 'URI="data:text/plain;base64,', + initDataBase64, '",\n', + ].join(''); + + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.addDrmInfo('com.widevine.alpha', (drmInfo) => { + drmInfo.addCencInitData(initDataBase64); + drmInfo.keyIds.add(keyId); + }); + }); + }); + manifest.sequenceMode = true; + }); + + fakeNetEngine.setResponseText('test:/master', master); + + const actual = await parser.start('test:/master', playerInterface); + expect(actual).toEqual(manifest); + }); + + it('for PlayReady', async () => { + const initDataBase64 = + 'AAAAKXBzc2gAAAAAmgTweZhAQoarkuZb4IhflQAAAAlQbGF5cmVhZHk='; + + const master = [ + '#EXTM3U\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1",', + 'RESOLUTION=960x540,FRAME-RATE=60\n', + 'video\n', + '#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES-CTR,', + 'KEYFORMAT="com.microsoft.playready",', + 'URI="data:text/plain;base64,UGxheXJlYWR5",\n', + ].join(''); + + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.addDrmInfo('com.microsoft.playready', (drmInfo) => { + drmInfo.addCencInitData(initDataBase64); + }); + }); + }); + manifest.sequenceMode = true; + }); + + fakeNetEngine.setResponseText('test:/master', master); + + const actual = await parser.start('test:/master', playerInterface); + expect(actual).toEqual(manifest); + }); + + it('for FairPlay', async () => { + const master = [ + '#EXTM3U\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1",', + 'RESOLUTION=960x540,FRAME-RATE=60\n', + 'video\n', + '#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES-CTR,', + 'KEYFORMAT="com.apple.streamingkeydelivery",', + 'URI="skd://f93d4e700d7ddde90529a27735d9e7cb",\n', + ].join(''); + + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.addDrmInfo('com.apple.fps', (drmInfo) => { + drmInfo.addInitData('sinf', new Uint8Array(0)); + }); + }); + }); + manifest.sequenceMode = true; + }); + + fakeNetEngine.setResponseText('test:/master', master); + + const actual = await parser.start('test:/master', playerInterface); + expect(actual).toEqual(manifest); + }); + + it('for ClearKey with explicit KEYFORMAT', async () => { + const master = [ + '#EXTM3U\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1",', + 'RESOLUTION=960x540,FRAME-RATE=60\n', + 'video\n', + '#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES-CTR,', + 'KEYFORMAT="identity",', + 'URI="key.bin",\n', + ].join(''); + + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.addDrmInfo('org.w3.clearkey'); + }); + }); + manifest.sequenceMode = true; + }); + + fakeNetEngine.setResponseText('test:/master', master); + + const actual = await parser.start('test:/master', playerInterface); + expect(actual).toEqual(manifest); + }); + + it('for ClearKey without explicit KEYFORMAT', async () => { + const master = [ + '#EXTM3U\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1",', + 'RESOLUTION=960x540,FRAME-RATE=60\n', + 'video\n', + '#EXT-X-SESSION-KEY:METHOD=SAMPLE-AES-CTR,', + 'URI="key.bin",\n', + ].join(''); + + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.addDrmInfo('org.w3.clearkey'); + }); + }); + manifest.sequenceMode = true; + }); + + fakeNetEngine.setResponseText('test:/master', master); + + const actual = await parser.start('test:/master', playerInterface); + expect(actual).toEqual(manifest); + }); + }); + it('falls back to mp4 if HEAD request fails', async () => { const master = [ '#EXTM3U\n',