Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ms:laurl and mspr:pro parsing #1010

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 186 additions & 1 deletion lib/dash/content_protection.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,179 @@ shaka.dash.ContentProtection.parseFromRepresentation = function(
return repContext.defaultKeyId || context.defaultKeyId;
};

/**
* Gets a Widevine license URL from a content protection element
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I am mistaken, 'ms:laurl' is a Microsoft specific element. I don't believe Widevine has a standard way of embedding URLs in the manifest.

* containing a custom `ms:laurl` element
*
* @param {shaka.dash.ContentProtection.Element} element
* @return {string}
* @private
*/
shaka.dash.ContentProtection.getWidevineLicenseUrl_ = function(element) {
var mslaurlNode = shaka.util.XmlUtils.findChild(element.node, 'ms:laurl');

if (mslaurlNode) {
return mslaurlNode.getAttribute('licenseUrl') || '';
}

return '';
};

/**
* @typedef {{
* type: number,
* length: number,
* value: string
* }}
*
* @description
* The parsed result of a PlayReady object record.
*
* @property {number} type
* Type of data stored in the record.
* @property {number} length
* Size of the record in bytes.
* @property {string} value
* Record content.
*/
shaka.dash.ContentProtection.PlayReadyRecord;

/**
* @typedef {{
* length: number,
* recordCount: number,
* records: Array.<shaka.dash.ContentProtection.PlayReadyRecord>,
* }}
*
* @description
* The parsed result of a PlayReady object.
*
* @property {number} length
* Size of the PlayReady header object in bytes.
* @property {number} recordCount
* Number of records in the PlayReady object.
* @property {Array.<shaka.dash.ContentProtection.PlayReadyRecord>} records
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For object types like Array and Uint16Array, use a ! to make it non-nullable. E.g. {!Array.<T>}.

* Contains a variable number of records that contain license acquisition information.
*/
shaka.dash.ContentProtection.PlayReadyHeaderObject;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You never use the length and recordCount members; how about just returning the array of records directly?


/**
* A map of PlayReady record types to description.
*
* @const {!Object.<number, string>}
* @private
*/
var PLAYREADY_RECORD_TYPES = {
0x0001 : 'RIGHTS_MANAGEMENT',
0x0002 : 'RESERVED',
0x0003 : 'EMBEDDED_LICENSE'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to reverse this enum:

/** @enum {number} */
var PLAYREADY_RECORD_TYPES = {
  RIGHTS_MANAGEMENT: 0x001,
  RESERVED: 0x002,
  EMBEDDED_LICENSE: 0x003
};

If you look at my suggestion below, it allows us to use the compiler to check we don't misspell the name. Your enum here just converts a magic number to a magic string. Also, our compiler will remove the enum completely, so it avoids an object lookup at runtime.

};

/**
* @param {Uint16Array} recordData
* @param {number} recordCount
* @return {Array.<shaka.dash.ContentProtection.PlayReadyRecord>}
* @private
*/
shaka.dash.ContentProtection.parseRecords_ = function(recordData, recordCount) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about renaming this so it is clear it is a 'mspr:pro' record? Something like parseMsProRecords_?

var head = 0;
var records = [];
for (var i = 0; i < recordCount; i++) {
var recordRaw = recordData.subarray(head);

var type = recordRaw[0];
var length = recordRaw[1];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here too, there are unchecked indexes. Can we add some checks to insure that the right amount of data is present before indexing?

var offset = 2;
var charCount = length / 2;
var end = charCount + offset;

// subarray end is exclusive
var rawValue = recordRaw.subarray(offset, end);
var value = String.fromCharCode.apply(null, rawValue);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use shaka.util.StringUtils.fromUTF8 to parse UTF-8 correctly and avoid a possible stack overflow with large strings.


records.push({
type: type,
length: length,
value: value
});

head = end;
}

return records;
};

/**
* Based off getLicenseServerURLFromInitData from dash.js
* https://github.com/Dash-Industry-Forum/dash.js
*
* @param {Uint16Array} bytes
* @return {shaka.dash.ContentProtection.PlayReadyHeaderObject}
* @private
*/
shaka.dash.ContentProtection.parsePro_ = function(bytes) {
var length = bytes[0] | bytes[1];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you forget a shift here? (bytes[0] << 16) | bytes[1]

var recordCount = bytes[2];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a guarantee that there will be at least 3 elements in bytes?


var recordData = bytes.subarray(3);
var records = shaka.dash.ContentProtection.parseRecords_(recordData, recordCount);

return {
length: length,
recordCount: recordCount,
records: records
};
};

/**
* @param {!Element} xml
* @return {string}
* @private
*/
shaka.dash.ContentProtection.getLaurl_ = function(xml) {
var laurlNode = xml.getElementsByTagName('LA_URL')[0];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a guarantee that there will be an element with the given tag name?

if (laurlNode) {
var laurl = laurlNode.childNodes[0].nodeValue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a guarantee that there will be a child node?


if (laurl) {
return laurl;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you reduce this down to:

var laurlNode = ...
return (laurlNode ? laurlNode.childNodes[0].nodeValue : null) || '';


return '';
};

/**
* Gets a PlayReady license URL from a content protection element
* containing a PlayReady Header Object
*
* @param {shaka.dash.ContentProtection.Element} element
* @return {string}
* @private
*/
shaka.dash.ContentProtection.getPlayReadyLicenseServerURL_ = function(element) {
var proNode = shaka.util.XmlUtils.findChild(element.node, 'mspr:pro');

if (!proNode) {
return '';
}

var bytes = shaka.util.Uint8ArrayUtils.fromBase64(proNode.textContent);
var parsedPro = shaka.dash.ContentProtection.parsePro_(new Uint16Array(bytes.buffer));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I remember correctly, Uint16Array uses the host byte order, whereas PlayReady uses little-endian. You may need to use DataView to read the data with the correct byte order.


var records = parsedPro.records;
var parser = new DOMParser();
for (var i = 0; i < records.length; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer it if we could avoid indexed array iteration. Can we do something like:

var record = records.filter(function(record) {
  return PLAYREADY_RECORD_TYPES[record.type] == 'RIGHTS_MANAGEMENT';
}).pop();

if (record) {
  var xml = ...
  return ...
} else {
  return ...
}

var record = records[i];
if (PLAYREADY_RECORD_TYPES[record.type] === 'RIGHTS_MANAGEMENT') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (record.type == PLAYREADY_RECORD_TYPES.RIGHTS_MANAGEMENT)

var xml = parser.parseFromString(record.value, 'application/xml').documentElement;

return shaka.dash.ContentProtection.getLaurl_(xml);
}
}

return '';
};

/**
* Creates DrmInfo objects from the given element.
Expand All @@ -276,7 +449,19 @@ shaka.dash.ContentProtection.convertElements_ = function(
goog.asserts.assert(!element.init || element.init.length,
'Init data must be null or non-empty.');
var initData = element.init || defaultInit;
return [ManifestParserUtils.createDrmInfo(keySystem, initData)];
var drmInfo = ManifestParserUtils.createDrmInfo(keySystem, initData);

// extract Widevine license URL
if (keySystem === 'com.widevine.alpha') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make it easier for us to add support for other schemes, could we structure this like:

var license_url_parsers = {
  'com.widevine.alpha': shaka.dash...
  'com.microsoft.playready': shaka.dash...
};

var license_parser = license_parsers[keySystem];

if (license_parser) {
  drmInfo.licenseServerUri = license_parser(element);
}

drmInfo.licenseServerUri = shaka.dash.ContentProtection.getWidevineLicenseUrl_(element);
}

// extract PlayReady license URL from PlayReady header Object
if (keySystem === 'com.microsoft.playready') {
drmInfo.licenseServerUri = shaka.dash.ContentProtection.getPlayReadyLicenseServerURL_(element);
}

return [drmInfo];
} else {
goog.asserts.assert(
callback, 'ContentProtection callback is required');
Expand Down
176 changes: 176 additions & 0 deletions test/dash/dash_parser_content_protection_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -717,3 +717,179 @@ describe('DashParser ContentProtection', function() {
Dash.testFails(done, source, expected);
});
});

describe('In-manifest PlayReady and Widevine', function() {
var ContentProtection = shaka.dash.ContentProtection;
var strToXml = function(str) {
var parser = new DOMParser();

return parser.parseFromString(str, 'application/xml').documentElement;
};

describe('getWidevineLicenseUrl_', function() {
it('valid ms:laurl node', function() {
var input = {
node: strToXml('<test><ms:laurl licenseUrl="www.example.com"></ms:laurl></test>')
};
var actual = ContentProtection.getWidevineLicenseUrl_(input);
var expected = 'www.example.com';
expect(actual).toEqual(expected);
});

it('ms:laurl without license url', function() {
var input = { node: strToXml('<test><ms:laurl></ms:laurl></test>') };
var actual = ContentProtection.getWidevineLicenseUrl_(input);
var expected = '';
expect(actual).toEqual(expected);
});

it('no ms:laurl node', function() {
var input = { node: strToXml('<test></test>') };
var actual = ContentProtection.getWidevineLicenseUrl_(input);
var expected = '';
expect(actual).toEqual(expected);
});
});

describe('parseRecords_', function() {
it('one record', function() {
var input = Uint16Array.from([
// Type
1,
// Size
2,
// Value
116
]);
var actual = ContentProtection.parseRecords_(input, 1);
var expected = [{ type: 1, length: 2, value: 't' }];
expect(actual).toEqual(expected);
});

it('multiple records', function() {
var input = Uint16Array.from([
1, 2, 116,
2, 2, 120
]);
var actual = ContentProtection.parseRecords_(input, 2);
var expected = [
{ type: 1, length: 2, value: 't' },
{ type: 2, length: 2, value: 'x' }
];
expect(actual).toEqual(expected);
});
});

describe('parsePro_', function() {
it('one record', function() {
var input = Uint16Array.from([
// PlayReady Object size
12, 0,
// Record count
1,

// Record
// Type
1,
// Size
2,
// Value
116
]);
var actual = ContentProtection.parsePro_(input);
var expected = {
length: 12, recordCount: 1,
records: [{ type: 1, length: 2, value: 't' }]
};
expect(actual).toEqual(expected);
});

it('multiple records', function() {
var input = Uint16Array.from([
// PlayReady Object
18, 0, 2,

// Record
1, 2, 116,

// Record
2, 2, 120
]);
var actual = ContentProtection.parsePro_(input);
var expected = {
length: 18, recordCount: 2,
records: [
{ type: 1, length: 2, value: 't' },
{ type: 2, length: 2, value: 'x' }
]
};
expect(actual).toEqual(expected);
});

it('no records', function() {
var input = Uint16Array.from([
// PlayReady Object
6, 0, 0,
]);
var actual = ContentProtection.parsePro_(input);
var expected = { length: 6, recordCount: 0, records: [] };
expect(actual).toEqual(expected);
});
});

describe('getLaurl_', function() {
it('contains LA_URL', function() {
var input = strToXml('<test><LA_URL>www.example.com</LA_URL></test>');
var actual = ContentProtection.getLaurl_(input);
var expected = 'www.example.com';
expect(actual).toEqual(expected);
});

it('does not contain LA_URL', function() {
var input = strToXml('<test></test>');
var actual = ContentProtection.getLaurl_(input);
var expected = '';
expect(actual).toEqual(expected);
});
});

describe('getPlayReadyLicenseServerURL_', function() {
it('mspro', function() {
var laurl = '<test><LA_URL>www.example.com</LA_URL></test>';
var laurlCodes = laurl.split('').map(function(c) {
return c.charCodeAt();
});
var prBytes = Uint16Array.from([
// pr object size (unused)
0, 0,
// record count
1,

// type
0x0001,
// record size
laurl.length * 2,
// value
].concat(laurlCodes));

var encodedPrObject = btoa(String.fromCharCode.apply(null, new Uint8Array(prBytes.buffer)));

var input = {
node: strToXml('<test><mspr:pro>' + encodedPrObject + '</mspr:pro></test>')
};

var actual = ContentProtection.getPlayReadyLicenseServerURL_(input);
var expected = 'www.example.com';
expect(actual).toEqual(expected);
});

it('no mspro', function() {
var input = {
node: strToXml('<test></test>')
};
var actual = ContentProtection.getPlayReadyLicenseServerURL_(input);
var expected = '';
expect(actual).toEqual(expected);
});
});
});