diff --git a/externs/isoboxer.js b/externs/isoboxer.js index 1a793f8835..ad3843ae4f 100644 --- a/externs/isoboxer.js +++ b/externs/isoboxer.js @@ -45,6 +45,7 @@ var ISOBoxerUtils; /** * @typedef {{ + * _parsing: boolean, * type: string, * size: number, * _parent: ISOBox, @@ -123,6 +124,7 @@ var ISOBoxerUtils; * sample_info_size: Array., * data_offset: number * }} + * @property {boolean} _parsing * @property {string} type * @property {number} size * @property {ISOBox} _parent diff --git a/lib/mss/content_protection.js b/lib/mss/content_protection.js index d63a8f6859..90cb5a5371 100644 --- a/lib/mss/content_protection.js +++ b/lib/mss/content_protection.js @@ -175,7 +175,17 @@ shaka.mss.ContentProtection = class { for (const elem of xml.getElementsByTagName('DATA')) { const kid = shaka.util.XmlUtils.findChild(elem, 'KID'); if (kid) { - return kid.textContent; + // GUID: [DWORD, WORD, WORD, 8-BYTE] + const guidBytes = + shaka.util.Uint8ArrayUtils.fromBase64(kid.textContent); + // Reverse byte order from little-endian to big-endian + const kidBytes = new Uint8Array([ + guidBytes[3], guidBytes[2], guidBytes[1], guidBytes[0], + guidBytes[5], guidBytes[4], + guidBytes[7], guidBytes[6], + ...guidBytes.slice(8), + ]); + return shaka.util.Uint8ArrayUtils.toHex(kidBytes); } } @@ -274,7 +284,7 @@ shaka.mss.ContentProtection = class { for (let i = 0; i < elements.length; i++) { const element = elements[i]; - const systemID = element.getAttribute('SystemID'); + const systemID = element.getAttribute('SystemID').toLowerCase(); const keySystem = keySystemsBySystemId[systemID]; if (keySystem) { const KID = ContentProtection.getPlayReadyKID_(element); diff --git a/lib/transmuxer/mss_transmuxer.js b/lib/transmuxer/mss_transmuxer.js index fe684e6f2e..af52baa060 100644 --- a/lib/transmuxer/mss_transmuxer.js +++ b/lib/transmuxer/mss_transmuxer.js @@ -98,14 +98,16 @@ shaka.transmuxer.MssTransmuxer = class { } }); // eslint-disable-next-line no-restricted-syntax - this.isoBoxer_.addBoxProcessor('senc', function() { + const sencProcessor = function() { // eslint-disable-next-line no-invalid-this const box = /** @type {!ISOBox} */(this); box._procFullBox(); - box._procField('sample_count', 'uint', 32); if (box.flags & 1) { + box._procField('AlgorithmID', 'uint', 24); box._procField('IV_size', 'uint', 8); + box._procFieldArray('KID', 16, 'uint', 8); } + box._procField('sample_count', 'uint', 32); // eslint-disable-next-line no-restricted-syntax box._procEntries('entry', box.sample_count, function(entry) { // eslint-disable-next-line no-invalid-this @@ -125,6 +127,30 @@ shaka.transmuxer.MssTransmuxer = class { }); } }); + }; + this.isoBoxer_.addBoxProcessor('senc', sencProcessor); + // eslint-disable-next-line no-restricted-syntax + this.isoBoxer_.addBoxProcessor('uuid', function() { + const MssTransmuxer = shaka.transmuxer.MssTransmuxer; + // eslint-disable-next-line no-invalid-this + const box = /** @type {!ISOBox} */(this); + let isSENC = true; + for (let i = 0; i < 16; i++) { + if (box.usertype[i] !== MssTransmuxer.UUID_SENC_[i]) { + isSENC = false; + } + // Add support for other user types here + } + + if (isSENC) { + if (box._parsing) { + // Convert this box to sepiff for later processing. + // See processMediaSegment_ function. + box.type = 'sepiff'; + } + // eslint-disable-next-line no-restricted-syntax, no-invalid-this + sencProcessor.call(/** @type {!ISOBox} */(this)); + } }); } @@ -345,6 +371,14 @@ shaka.transmuxer.MssTransmuxer = class { } }; +/** + * @private {!Uint8Array} + */ +shaka.transmuxer.MssTransmuxer.UUID_SENC_ = new Uint8Array([ + 0xA2, 0x39, 0x4F, 0x52, 0x5A, 0x9B, 0x4F, 0x14, + 0xA2, 0x44, 0x6C, 0x42, 0x7C, 0x64, 0x8D, 0xF4, +]); + shaka.transmuxer.TransmuxerEngine.registerTransmuxer( 'mss/audio/mp4', () => new shaka.transmuxer.MssTransmuxer('mss/audio/mp4'), diff --git a/test/mss/mss_parser_unit.js b/test/mss/mss_parser_unit.js index 23d8b1e5ad..2ce8666b74 100644 --- a/test/mss/mss_parser_unit.js +++ b/test/mss/mss_parser_unit.js @@ -24,6 +24,27 @@ describe('MssParser Manifest', () => { const aacCodecPrivateData = '1210'; + // From https://test.playready.microsoft.com/smoothstreaming/SSWSS720H264PR/S + // uperSpeedway_720.ism/Manifest + const protectionHeader = 'jAMAAAEAAQCCAzwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0A' + + 'bABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzA' + + 'G8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQ' + + 'BhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4' + + 'AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZ' + + 'AEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQ' + + 'wBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AE' + + 'sASQBEAD4AQQBtAGYAagBDAFQATwBQAGIARQBPAGwAMwBXAEQALwA1AG0AYwBlAGMAQQA' + + '9AD0APAAvAEsASQBEAD4APABDAEgARQBDAEsAUwBVAE0APgBCAEcAdwAxAGEAWQBaADEA' + + 'WQBYAE0APQA8AC8AQwBIAEUAQwBLAFMAVQBNAD4APABDAFUAUwBUAE8ATQBBAFQAVABSA' + + 'EkAQgBVAFQARQBTAD4APABJAEkAUwBfAEQAUgBNAF8AVgBFAFIAUwBJAE8ATgA+ADcALg' + + 'AxAC4AMQAwADYANAAuADAAPAAvAEkASQBTAF8ARABSAE0AXwBWAEUAUgBTAEkATwBOAD4' + + 'APAAvAEMAVQBTAFQATwBNAEEAVABUAFIASQBCAFUAVABFAFMAPgA8AEwAQQBfAFUAUgBM' + + 'AD4AaAB0AHQAcAA6AC8ALwBwAGwAYQB5AHIAZQBhAGQAeQAuAGQAaQByAGUAYwB0AHQAY' + + 'QBwAHMALgBuAGUAdAAvAHAAcgAvAHMAdgBjAC8AcgBpAGcAaAB0AHMAbQBhAG4AYQBnAG' + + 'UAcgAuAGEAcwBtAHgAPAAvAEwAQQBfAFUAUgBMAD4APABEAFMAXwBJAEQAPgBBAEgAKwA' + + 'wADMAagB1AEsAYgBVAEcAYgBIAGwAMQBWAC8AUQBJAHcAUgBBAD0APQA8AC8ARABTAF8A' + + 'SQBEAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA='; + /** @param {!shaka.extern.Manifest} manifest */ async function loadAllStreamsFor(manifest) { const promises = []; @@ -113,7 +134,7 @@ describe('MssParser Manifest', () => { await Mss.testFails(source, error); }); - it('ive content ', async () => { + it('live content ', async () => { const source = [ '', ' ', @@ -417,4 +438,35 @@ describe('MssParser Manifest', () => { expect(variant.audio).toBeTruthy(); expect(variant.video).toBeTruthy(); }); + + it('recognizes PlayReady System ID with mixed cases', async () => { + const manifestText = [ + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + protectionHeader, + ' ', + ' ', + '', + ].join('\n'); + + fakeNetEngine.setResponseText('dummy://foo', manifestText); + + /** @type {shaka.extern.Manifest} */ + const manifest = await parser.start('dummy://foo', playerInterface); + const variant = manifest.variants[0]; + expect(variant.video.drmInfos.length).toBe(1); + expect(variant.video.drmInfos[0].keySystem).toBe('com.microsoft.playready'); + // Also able to parse KID correctly + expect(variant.video.drmInfos[0].keyIds.size).toBe(1); + // Expected KID: https://testweb.playready.microsoft.com/Content/Content2X + expect([...(variant.video.drmInfos[0].keyIds.values())][0]).toBe( + '09E367028F33436CA5DD60FFE6671E70'.toLowerCase()); + }); }); diff --git a/test/mss/mss_player_integration.js b/test/mss/mss_player_integration.js index e136df95ef..ffc5b6e375 100644 --- a/test/mss/mss_player_integration.js +++ b/test/mss/mss_player_integration.js @@ -25,6 +25,12 @@ describe('MSS Player', () => { // eslint-disable-next-line max-len const url = 'https://playready.directtaps.net/smoothstreaming/SSWSS720H264/SuperSpeedway_720.ism/Manifest'; + // eslint-disable-next-line max-len + const playreadyUrl = 'https://playready.directtaps.net/smoothstreaming/SSWSS720H264/SuperSpeedway_720.ism/Manifest'; + + // eslint-disable-next-line max-len + const playreadyLicenseUrl = 'https://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(persist:false,sl:150)'; + beforeAll(async () => { video = shaka.test.UiUtils.createVideoElement(); document.body.appendChild(video); @@ -81,4 +87,44 @@ describe('MSS Player', () => { await player.unload(); }); + + it('MSS VoD PlayReady', async () => { + const support = await shaka.media.DrmEngine.probeSupport(); + if (!support['com.microsoft.playready']) { + return; + } + // Make sure we are playing the lowest res available to avoid test flake + // based on network issues. Note that disabling ABR and setting a low + // abr.defaultBandwidthEstimate would not be sufficient, because it + // would only affect the choice of track on the first period. When we + // cross a period boundary, the default bandwidth estimate will no + // longer be in effect, and AbrManager may choose higher res tracks for + // the new period. Using abr.restrictions.maxHeight will let us force + // AbrManager to the lowest resolution, which is its fallback when these + // soft restrictions cannot be met. + player.configure('abr.restrictions.maxHeight', 1); + + player.configure({ + drm: { + servers: { + 'com.microsoft.playready': playreadyLicenseUrl, + }, + }, + }); + + await player.load(playreadyUrl, /* startTime= */ null, + /* mimeType= */ 'application/vnd.ms-sstr+xml'); + video.play(); + expect(player.isLive()).toBe(false); + + // Wait for the video to start playback. If it takes longer than 10 + // seconds, fail the test. + await waiter.waitForMovementOrFailOnTimeout(video, 10); + + // Play for 5 seconds, but stop early if the video ends. If it takes + // longer than 10 seconds, fail the test. + await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 5, 10); + + await player.unload(); + }); });