diff --git a/lib/dash/content_protection.js b/lib/dash/content_protection.js index 31b705da4d..35ce5556fb 100644 --- a/lib/dash/content_protection.js +++ b/lib/dash/content_protection.js @@ -262,6 +262,188 @@ shaka.dash.ContentProtection.parseFromRepresentation = function( }; +/** + * Gets a Widevine license URL from a content protection element + * containing a custom `ms:laurl` element + * + * @param {shaka.dash.ContentProtection.Element} element + * @return {string} + */ +shaka.dash.ContentProtection.getWidevineLicenseUrl = function(element) { + const mslaurlNode = shaka.util.XmlUtils.findChildNS( + element.node, 'urn:microsoft', 'laurl'); + if (mslaurlNode) { + return mslaurlNode.getAttribute('licenseUrl') || ''; + } + return ''; +}; + + +/** + * @typedef {{ + * type: number, + * value: !Uint8Array + * }} + * + * @description + * The parsed result of a PlayReady object record. + * + * @property {number} type + * Type of data stored in the record. + * @property {!Uint8Array} value + * Record content. + */ +shaka.dash.ContentProtection.PlayReadyRecord; + +/** + * Enum for PlayReady record types. + * @enum {number} + */ +shaka.dash.ContentProtection.PLAYREADY_RECORD_TYPES = { + RIGHTS_MANAGEMENT: 0x001, + RESERVED: 0x002, + EMBEDDED_LICENSE: 0x003, +}; + + +/** + * Parses an Array buffer starting at byteOffset for + * PlayReady Object Records. Each PRO Record is + * preceeded by it's PlayReady Record type and length + * in bytes. + * + * PlayReady Object Record format: https://goo.gl/FTcu46 + * + * @param {!ArrayBuffer} recordData + * @param {number} byteOffset + * @return {!Array.} + * @private + */ +shaka.dash.ContentProtection.parseMsProRecords_ = function( + recordData, byteOffset) { + const records = []; + const view = new DataView(recordData); + + while (byteOffset < recordData.byteLength - 1) { + const type = view.getUint16(byteOffset, true); + byteOffset += 2; + + const byteLength = view.getUint16(byteOffset, true); + byteOffset += 2; + + goog.asserts.assert( + (byteLength & 1) === 0, + 'expected byteLength to be an even number'); + + const recordValue = new Uint8Array(recordData, byteOffset, byteLength); + + records.push({ + type: type, + value: recordValue, + }); + + byteOffset += byteLength; + } + + return records; +}; + + +/** + * Parses an ArrayBuffer for PlayReady Objects. The data + * should contain a 32-bit integer indicating the length of + * the PRO in bytes. Following that, a 16-bit integer for + * the number of PlayReady Object Records in the PRO. Lastly, + * a byte array of the PRO Records themselves. + * + * PlayReady Object format: https://goo.gl/W8yAN4 + * + * @param {!ArrayBuffer} data + * @return {!Array.} + * @private + */ +shaka.dash.ContentProtection.parseMsPro_ = function(data) { + let byteOffset = 0; + const view = new DataView(data); + + // First 4 bytes is the PRO length (DWORD) + const byteLength = view.getUint32(byteOffset, true /* littleEndian */); + byteOffset += 4; + + if (byteLength !== data.byteLength) { + // Malformed PRO + shaka.log.warning('PlayReady Object with invalid length encountered.'); + return []; + } + + // Skip PRO Record count (WORD) + byteOffset += 2; + + // Rest of the data contains the PRO Records + const ContentProtection = shaka.dash.ContentProtection; + return ContentProtection.parseMsProRecords_(data, byteOffset); +}; + + +/** + * PlayReady Header format: https://goo.gl/dBzxNA + * + * @param {!Element} xml + * @return {string} + * @private + */ +shaka.dash.ContentProtection.getLaurl_ = function(xml) { + // LA_URL element is optional and no more than one is + // allowed inside the DATA element. Only absolute URLs are allowed. + // If the LA_URL element exists, it must not be empty. + const laurlNode = xml.querySelector('DATA > LA_URL'); + if (laurlNode) { + return laurlNode.textContent; + } + + // Not found + return ''; +}; + + +/** + * Gets a PlayReady license URL from a content protection element + * containing a PlayReady Header Object + * + * @param {shaka.dash.ContentProtection.Element} element + * @return {string} + */ +shaka.dash.ContentProtection.getPlayReadyLicenseUrl = function(element) { + const proNode = shaka.util.XmlUtils.findChildNS( + element.node, 'urn:microsoft:playready', 'pro'); + + if (!proNode) { + return ''; + } + + const ContentProtection = shaka.dash.ContentProtection; + const PLAYREADY_RECORD_TYPES = ContentProtection.PLAYREADY_RECORD_TYPES; + + const bytes = shaka.util.Uint8ArrayUtils.fromBase64(proNode.textContent); + const records = ContentProtection.parseMsPro_(bytes.buffer); + const record = records.filter((record) => { + return record.type === PLAYREADY_RECORD_TYPES.RIGHTS_MANAGEMENT; + })[0]; + + if (!record) { + return ''; + } + + const xml = shaka.util.StringUtils.fromUTF16(record.value, true); + const rootElement = shaka.util.XmlUtils.parseXmlString(xml, 'WRMHEADER'); + if (!rootElement) { + return ''; + } + + return ContentProtection.getLaurl_(rootElement); +}; + + /** * Creates DrmInfo objects from the given element. * @@ -272,9 +454,11 @@ shaka.dash.ContentProtection.parseFromRepresentation = function( * @private */ shaka.dash.ContentProtection.convertElements_ = function( - defaultInit, callback, elements) { + defaultInit, callback, elements) { + const ContentProtection = shaka.dash.ContentProtection; const createDrmInfo = shaka.util.ManifestParserUtils.createDrmInfo; - const defaultKeySystems = shaka.dash.ContentProtection.defaultKeySystems_; + const defaultKeySystems = ContentProtection.defaultKeySystems_; + const licenseUrlParsers = ContentProtection.licenseUrlParsers_; /** @type {!Array.} */ const out = []; @@ -288,6 +472,11 @@ shaka.dash.ContentProtection.convertElements_ = function( const initData = element.init || defaultInit; const info = createDrmInfo(keySystem, initData); + const licenseParser = licenseUrlParsers.get(keySystem); + if (licenseParser) { + info.licenseServerUri = licenseParser(element); + } + out.push(info); } else { goog.asserts.assert(callback, 'ContentProtection callback is required'); @@ -302,6 +491,19 @@ shaka.dash.ContentProtection.convertElements_ = function( }; +/** + * A map of key system name to license server url parser. + * + * @const {!Map.} + * @private + */ +shaka.dash.ContentProtection.licenseUrlParsers_ = new Map() + .set('com.widevine.alpha', + shaka.dash.ContentProtection.getWidevineLicenseUrl) + .set('com.microsoft.playready', + shaka.dash.ContentProtection.getPlayReadyLicenseUrl); + + /** * Parses the given ContentProtection elements. If there is an error, it * removes those elements. diff --git a/lib/dash/dash_parser.js b/lib/dash/dash_parser.js index 03252bfa28..ac5126bf7d 100644 --- a/lib/dash/dash_parser.js +++ b/lib/dash/dash_parser.js @@ -435,7 +435,7 @@ shaka.dash.DashParser.prototype.parseManifest_ = const Error = shaka.util.Error; const MpdUtils = shaka.dash.MpdUtils; - let mpd = MpdUtils.parseXml(data, 'MPD'); + let mpd = shaka.util.XmlUtils.parseXml(data, 'MPD'); if (!mpd) { throw new Error( Error.Severity.CRITICAL, Error.Category.MANIFEST, diff --git a/lib/dash/mpd_utils.js b/lib/dash/mpd_utils.js index 0b109a0f42..299f57ac2c 100644 --- a/lib/dash/mpd_utils.js +++ b/lib/dash/mpd_utils.js @@ -24,7 +24,6 @@ goog.require('shaka.util.AbortableOperation'); goog.require('shaka.util.Error'); goog.require('shaka.util.Functional'); goog.require('shaka.util.ManifestParserUtils'); -goog.require('shaka.util.StringUtils'); goog.require('shaka.util.XmlUtils'); @@ -433,36 +432,6 @@ shaka.dash.MpdUtils.inheritChild = function(context, callback, child) { }; -/** - * Parse some UTF8 data and return the resulting root element if - * it was valid XML. - * @param {ArrayBuffer} data - * @param {string} expectedRootElemName - * @return {Element|undefined} - */ -shaka.dash.MpdUtils.parseXml = function(data, expectedRootElemName) { - let parser = new DOMParser(); - let rootElem; - let xml; - try { - let string = shaka.util.StringUtils.fromUTF8(data); - xml = parser.parseFromString(string, 'text/xml'); - } catch (exception) {} - if (xml) { - // The top-level element in the loaded xml should have the - // same type as the element linking. - if (xml.documentElement.tagName == expectedRootElemName) { - rootElem = xml.documentElement; - } - } - if (rootElem && rootElem.getElementsByTagName('parsererror').length > 0) { - return null; - } // It had a parser error in it. - - return rootElem; -}; - - /** * Follow the xlink link contained in the given element. * It also strips the xlink properties off of the element, @@ -536,7 +505,7 @@ shaka.dash.MpdUtils.handleXlinkInElement_ = return networkOperation.chain((response) => { // This only supports the case where the loaded xml has a single // top-level element. If there are multiple roots, it will be rejected. - let rootElem = MpdUtils.parseXml(response.data, element.tagName); + let rootElem = shaka.util.XmlUtils.parseXml(response.data, element.tagName); if (!rootElem) { // It was not valid XML. return shaka.util.AbortableOperation.failed(new Error( diff --git a/lib/util/xml_utils.js b/lib/util/xml_utils.js index c0e9b93361..f7366b01c5 100644 --- a/lib/util/xml_utils.js +++ b/lib/util/xml_utils.js @@ -18,6 +18,7 @@ goog.provide('shaka.util.XmlUtils'); goog.require('shaka.log'); +goog.require('shaka.util.StringUtils'); /** @@ -43,6 +44,24 @@ shaka.util.XmlUtils.findChild = function(elem, name) { }; +/** + * Finds a namespace-qualified child XML element. + * @param {!Node} elem The parent XML element. + * @param {string} ns The child XML element's namespace URI. + * @param {string} name The child XML element's local name. + * @return {Element} The child XML element, or null if a child XML element does + * not exist with the given tag name OR if there exists more than one + * child XML element with the given tag name. + */ +shaka.util.XmlUtils.findChildNS = function(elem, ns, name) { + let children = shaka.util.XmlUtils.findChildrenNS(elem, ns, name); + if (children.length != 1) { + return null; + } + return children[0]; +}; + + /** * Finds child XML elements. * @param {!Node} elem The parent XML element. @@ -286,3 +305,47 @@ shaka.util.XmlUtils.evalDivision = function(exprString) { } return !isNaN(n) ? n : null; }; + + +/** + * Parse a string and return the resulting root element if + * it was valid XML. + * @param {string} xmlString + * @param {string} expectedRootElemName + * @return {Element|undefined} + */ +shaka.util.XmlUtils.parseXmlString = function(xmlString, expectedRootElemName) { + const parser = new DOMParser(); + let rootElem; + let xml; + try { + xml = parser.parseFromString(xmlString, 'text/xml'); + } catch (exception) {} + if (xml) { + // The top-level element in the loaded xml should have the + // same type as the element linking. + if (xml.documentElement.tagName == expectedRootElemName) { + rootElem = xml.documentElement; + } + } + if (rootElem && rootElem.getElementsByTagName('parsererror').length > 0) { + return null; + } // It had a parser error in it. + + return rootElem; +}; + + +/** + * Parse some UTF8 data and return the resulting root element if + * it was valid XML. + * @param {ArrayBuffer} data + * @param {string} expectedRootElemName + * @return {Element|undefined} + */ +shaka.util.XmlUtils.parseXml = function(data, expectedRootElemName) { + try { + const string = shaka.util.StringUtils.fromUTF8(data); + return shaka.util.XmlUtils.parseXmlString(string, expectedRootElemName); + } catch (exception) {} +}; diff --git a/test/dash/dash_parser_content_protection_unit.js b/test/dash/dash_parser_content_protection_unit.js index 0d09c31077..2e1755d311 100644 --- a/test/dash/dash_parser_content_protection_unit.js +++ b/test/dash/dash_parser_content_protection_unit.js @@ -741,3 +741,113 @@ describe('DashParser ContentProtection', function() { await Dash.testFails(source, expected); }); }); + +describe('In-manifest PlayReady and Widevine', function() { + const ContentProtection = shaka.dash.ContentProtection; + const strToXml = function(str) { + const parser = new DOMParser(); + return parser.parseFromString(str, 'application/xml').documentElement; + }; + + describe('getWidevineLicenseUrl', function() { + it('valid ms:laurl node', function() { + const input = { + init: null, + keyId: null, + schemeUri: '', + node: strToXml([ + '', + ' ', + '', + ].join('\n')), + }; + const actual = ContentProtection.getWidevineLicenseUrl(input); + const expected = 'www.example.com'; + expect(actual).toEqual(expected); + }); + + it('ms:laurl without license url', function() { + const input = { + init: null, + keyId: null, + schemeUri: '', + node: strToXml([ + '', + ' ', + '', + ].join('\n')), + }; + const actual = ContentProtection.getWidevineLicenseUrl(input); + const expected = ''; + expect(actual).toEqual(expected); + }); + + it('no ms:laurl node', function() { + const input = { + init: null, + keyId: null, + schemeUri: '', + node: strToXml(''), + }; + const actual = ContentProtection.getWidevineLicenseUrl(input); + const expected = ''; + expect(actual).toEqual(expected); + }); + }); + + describe('getPlayReadyLicenseURL', function() { + it('mspro', function() { + const laurl = [ + '', + ' ', + ' www.example.com', + ' ', + '', + ].join('\n'); + const laurlCodes = laurl.split('').map(function(c) { + return c.charCodeAt(); + }); + const prBytes = new Uint16Array([ + // pr object size (in num bytes). + // + 10 for PRO size, count, and type + laurl.length * 2 + 10, 0, + // record count + 1, + // type + ContentProtection.PLAYREADY_RECORD_TYPES.RIGHTS_MANAGEMENT, + // record size (in num bytes) + laurl.length * 2, + // value + ].concat(laurlCodes)); + + const encodedPrObject = + shaka.util.Uint8ArrayUtils.toBase64(new Uint8Array(prBytes.buffer)); + const input = { + init: null, + keyId: null, + schemeUri: '', + node: + strToXml([ + '', + ' ' + encodedPrObject + '', + '', + ].join('\n')), + }; + const actual = ContentProtection.getPlayReadyLicenseUrl(input); + const expected = 'www.example.com'; + expect(actual).toEqual(expected); + }); + + it('no mspro', function() { + const input = { + init: null, + keyId: null, + schemeUri: '', + node: strToXml(''), + }; + const actual = ContentProtection.getPlayReadyLicenseUrl(input); + const expected = ''; + expect(actual).toEqual(expected); + }); + }); +});