mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-16 16:16:40 +03:00
a6d06cc104
Issue #1157 Change-Id: I9c269bb82e791efbb5df483a4ff1701b7739a96f
572 lines
18 KiB
JavaScript
572 lines
18 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2016 Google Inc.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
goog.provide('shaka.dash.ContentProtection');
|
|
|
|
goog.require('goog.asserts');
|
|
goog.require('shaka.log');
|
|
goog.require('shaka.util.Error');
|
|
goog.require('shaka.util.ManifestParserUtils');
|
|
goog.require('shaka.util.Uint8ArrayUtils');
|
|
goog.require('shaka.util.XmlUtils');
|
|
|
|
|
|
/**
|
|
* @summary A set of functions for parsing and interpreting ContentProtection
|
|
* elements.
|
|
*/
|
|
shaka.dash.ContentProtection = class {
|
|
/**
|
|
* Parses info from the ContentProtection elements at the AdaptationSet level.
|
|
*
|
|
* @param {!Array.<!Element>} elems
|
|
* @param {shaka.extern.DashContentProtectionCallback} callback
|
|
* @param {boolean} ignoreDrmInfo
|
|
* @return {shaka.dash.ContentProtection.Context}
|
|
*/
|
|
static parseFromAdaptationSet(elems, callback, ignoreDrmInfo) {
|
|
const ContentProtection = shaka.dash.ContentProtection;
|
|
const ManifestParserUtils = shaka.util.ManifestParserUtils;
|
|
const parsed = ContentProtection.parseElements_(elems);
|
|
/** @type {Array.<shaka.extern.InitDataOverride>} */
|
|
let defaultInit = null;
|
|
/** @type {!Array.<shaka.extern.DrmInfo>} */
|
|
let drmInfos = [];
|
|
let parsedNonCenc = [];
|
|
|
|
// Get the default key ID; if there are multiple, they must all match.
|
|
const keyIds = new Set(parsed.map((element) => element.keyId));
|
|
// Remove any possible null value (elements may have no key ids).
|
|
keyIds.delete(null);
|
|
|
|
if (keyIds.size > 1) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code.DASH_CONFLICTING_KEY_IDS);
|
|
}
|
|
|
|
if (!ignoreDrmInfo) {
|
|
// Find the default key ID and init data. Create a new array of all the
|
|
// non-CENC elements.
|
|
parsedNonCenc = parsed.filter((elem) => {
|
|
if (elem.schemeUri == ContentProtection.MP4Protection_) {
|
|
goog.asserts.assert(!elem.init || elem.init.length,
|
|
'Init data must be null or non-empty.');
|
|
defaultInit = elem.init || defaultInit;
|
|
return false;
|
|
} else {
|
|
return true;
|
|
}
|
|
});
|
|
|
|
if (parsedNonCenc.length) {
|
|
drmInfos = ContentProtection.convertElements_(
|
|
defaultInit, callback, parsedNonCenc);
|
|
|
|
// If there are no drmInfos after parsing, then add a dummy entry.
|
|
// This may be removed in parseKeyIds.
|
|
if (drmInfos.length == 0) {
|
|
drmInfos = [ManifestParserUtils.createDrmInfo('', defaultInit)];
|
|
}
|
|
}
|
|
}
|
|
|
|
// If there are only CENC element(s) or ignoreDrmInfo flag is set, assume
|
|
// all key-systems are supported.
|
|
if (parsed.length && (ignoreDrmInfo || !parsedNonCenc.length)) {
|
|
drmInfos = [];
|
|
|
|
const keySystems = ContentProtection.defaultKeySystems_;
|
|
for (const keySystem of keySystems.values()) {
|
|
// If the manifest doesn't specify any key systems, we shouldn't
|
|
// put clearkey in this list. Otherwise, it may be triggered when
|
|
// a real key system should be used instead.
|
|
if (keySystem != 'org.w3.clearkey') {
|
|
const info =
|
|
ManifestParserUtils.createDrmInfo(keySystem, defaultInit);
|
|
drmInfos.push(info);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we have a default key id, apply it to every initData.
|
|
const defaultKeyId = Array.from(keyIds)[0] || null;
|
|
|
|
if (defaultKeyId) {
|
|
for (const info of drmInfos) {
|
|
for (const initData of info.initData) {
|
|
initData.keyId = defaultKeyId;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
defaultKeyId: defaultKeyId,
|
|
defaultInit: defaultInit,
|
|
drmInfos: drmInfos,
|
|
firstRepresentation: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parses the given ContentProtection elements found at the Representation
|
|
* level. This may update the |context|.
|
|
*
|
|
* @param {!Array.<!Element>} elems
|
|
* @param {shaka.extern.DashContentProtectionCallback} callback
|
|
* @param {shaka.dash.ContentProtection.Context} context
|
|
* @param {boolean} ignoreDrmInfo
|
|
* @return {?string} The parsed key ID
|
|
*/
|
|
static parseFromRepresentation(elems, callback, context, ignoreDrmInfo) {
|
|
const ContentProtection = shaka.dash.ContentProtection;
|
|
const repContext = ContentProtection.parseFromAdaptationSet(
|
|
elems, callback, ignoreDrmInfo);
|
|
|
|
if (context.firstRepresentation) {
|
|
const asUnknown = context.drmInfos.length == 1 &&
|
|
!context.drmInfos[0].keySystem;
|
|
const asUnencrypted = context.drmInfos.length == 0;
|
|
const repUnencrypted = repContext.drmInfos.length == 0;
|
|
|
|
// There are two cases where we need to replace the |drmInfos| in the
|
|
// context with those in the Representation:
|
|
// 1. The AdaptationSet does not list any ContentProtection.
|
|
// 2. The AdaptationSet only lists unknown key-systems.
|
|
if (asUnencrypted || (asUnknown && !repUnencrypted)) {
|
|
context.drmInfos = repContext.drmInfos;
|
|
}
|
|
context.firstRepresentation = false;
|
|
} else if (repContext.drmInfos.length > 0) {
|
|
// If this is not the first Representation, then we need to remove entries
|
|
// from the context that do not appear in this Representation.
|
|
context.drmInfos = context.drmInfos.filter((asInfo) => {
|
|
return repContext.drmInfos.some((repInfo) => {
|
|
return repInfo.keySystem == asInfo.keySystem;
|
|
});
|
|
});
|
|
// If we have filtered out all key-systems, throw an error.
|
|
if (context.drmInfos.length == 0) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code.DASH_NO_COMMON_KEY_SYSTEM);
|
|
}
|
|
}
|
|
|
|
return repContext.defaultKeyId || context.defaultKeyId;
|
|
}
|
|
|
|
/**
|
|
* Gets a Widevine license URL from a content protection element
|
|
* containing a custom `ms:laurl` element
|
|
*
|
|
* @param {shaka.dash.ContentProtection.Element} element
|
|
* @return {string}
|
|
*/
|
|
static getWidevineLicenseUrl(element) {
|
|
const mslaurlNode = shaka.util.XmlUtils.findChildNS(
|
|
element.node, 'urn:microsoft', 'laurl');
|
|
if (mslaurlNode) {
|
|
return mslaurlNode.getAttribute('licenseUrl') || '';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Parses an Array buffer starting at byteOffset for PlayReady Object Records.
|
|
* Each PRO Record is preceded by its PlayReady Record type and length in
|
|
* bytes.
|
|
*
|
|
* PlayReady Object Record format: https://goo.gl/FTcu46
|
|
*
|
|
* @param {!ArrayBuffer} recordData
|
|
* @param {number} byteOffset
|
|
* @return {!Array.<shaka.dash.ContentProtection.PlayReadyRecord>}
|
|
* @private
|
|
*/
|
|
static parseMsProRecords_(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.<shaka.dash.ContentProtection.PlayReadyRecord>}
|
|
* @private
|
|
*/
|
|
static parseMsPro_(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
|
|
*/
|
|
static getLaurl_(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}
|
|
*/
|
|
static getPlayReadyLicenseUrl(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.
|
|
*
|
|
* @param {Array.<shaka.extern.InitDataOverride>} defaultInit
|
|
* @param {shaka.extern.DashContentProtectionCallback} callback
|
|
* @param {!Array.<shaka.dash.ContentProtection.Element>} elements
|
|
* @return {!Array.<shaka.extern.DrmInfo>}
|
|
* @private
|
|
*/
|
|
static convertElements_(defaultInit, callback, elements) {
|
|
const ContentProtection = shaka.dash.ContentProtection;
|
|
const ManifestParserUtils = shaka.util.ManifestParserUtils;
|
|
const defaultKeySystems = ContentProtection.defaultKeySystems_;
|
|
const licenseUrlParsers = ContentProtection.licenseUrlParsers_;
|
|
|
|
/** @type {!Array.<shaka.extern.DrmInfo>} */
|
|
const out = [];
|
|
|
|
for (const element of elements) {
|
|
const keySystem = defaultKeySystems.get(element.schemeUri);
|
|
if (keySystem) {
|
|
goog.asserts.assert(
|
|
!element.init || element.init.length,
|
|
'Init data must be null or non-empty.');
|
|
|
|
const initData = element.init || defaultInit;
|
|
const info = ManifestParserUtils.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');
|
|
const infos = callback(element.node) || [];
|
|
for (const info of infos) {
|
|
out.push(info);
|
|
}
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Parses the given ContentProtection elements. If there is an error, it
|
|
* removes those elements.
|
|
*
|
|
* @param {!Array.<!Element>} elems
|
|
* @return {!Array.<shaka.dash.ContentProtection.Element>}
|
|
* @private
|
|
*/
|
|
static parseElements_(elems) {
|
|
/** @type {!Array.<shaka.dash.ContentProtection.Element>} */
|
|
const out = [];
|
|
|
|
for (const elem of elems) {
|
|
const parsed = shaka.dash.ContentProtection.parseElement_(elem);
|
|
if (parsed) {
|
|
out.push(parsed);
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Parses the given ContentProtection element.
|
|
*
|
|
* @param {!Element} elem
|
|
* @return {?shaka.dash.ContentProtection.Element}
|
|
* @private
|
|
*/
|
|
static parseElement_(elem) {
|
|
const NS = shaka.dash.ContentProtection.CencNamespaceUri_;
|
|
|
|
/** @type {?string} */
|
|
let schemeUri = elem.getAttribute('schemeIdUri');
|
|
/** @type {?string} */
|
|
let keyId = shaka.util.XmlUtils.getAttributeNS(elem, NS, 'default_KID');
|
|
/** @type {!Array.<string>} */
|
|
const psshs = shaka.util.XmlUtils.findChildrenNS(elem, NS, 'pssh')
|
|
.map(shaka.util.XmlUtils.getContents);
|
|
|
|
if (!schemeUri) {
|
|
shaka.log.error('Missing required schemeIdUri attribute on',
|
|
'ContentProtection element', elem);
|
|
return null;
|
|
}
|
|
|
|
schemeUri = schemeUri.toLowerCase();
|
|
if (keyId) {
|
|
keyId = keyId.replace(/-/g, '').toLowerCase();
|
|
if (keyId.includes(' ')) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code.DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED);
|
|
}
|
|
}
|
|
|
|
/** @type {!Array.<shaka.extern.InitDataOverride>} */
|
|
let init = [];
|
|
try {
|
|
// Try parsing PSSH data.
|
|
init = psshs.map((pssh) => {
|
|
return {
|
|
initDataType: 'cenc',
|
|
initData: shaka.util.Uint8ArrayUtils.fromBase64(pssh),
|
|
keyId: null,
|
|
};
|
|
});
|
|
} catch (e) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code.DASH_PSSH_BAD_ENCODING);
|
|
}
|
|
|
|
return {
|
|
node: elem,
|
|
schemeUri: schemeUri,
|
|
keyId: keyId,
|
|
init: (init.length > 0 ? init : null),
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @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,
|
|
};
|
|
|
|
/**
|
|
* @typedef {{
|
|
* defaultKeyId: ?string,
|
|
* defaultInit: Array.<shaka.extern.InitDataOverride>,
|
|
* drmInfos: !Array.<shaka.extern.DrmInfo>,
|
|
* firstRepresentation: boolean
|
|
* }}
|
|
*
|
|
* @description
|
|
* Contains information about the ContentProtection elements found at the
|
|
* AdaptationSet level.
|
|
*
|
|
* @property {?string} defaultKeyId
|
|
* The default key ID to use. This is used by parseKeyIds as a default. This
|
|
* can be null to indicate that there is no default.
|
|
* @property {Array.<shaka.extern.InitDataOverride>} defaultInit
|
|
* The default init data override. This can be null to indicate that there
|
|
* is no default.
|
|
* @property {!Array.<shaka.extern.DrmInfo>} drmInfos
|
|
* The DrmInfo objects.
|
|
* @property {boolean} firstRepresentation
|
|
* True when first parsed; changed to false after the first call to
|
|
* parseKeyIds. This is used to determine if a dummy key-system should be
|
|
* overwritten; namely that the first representation can replace the dummy
|
|
* from the AdaptationSet.
|
|
*/
|
|
shaka.dash.ContentProtection.Context;
|
|
|
|
|
|
/**
|
|
* @typedef {{
|
|
* node: !Element,
|
|
* schemeUri: string,
|
|
* keyId: ?string,
|
|
* init: Array.<shaka.extern.InitDataOverride>
|
|
* }}
|
|
*
|
|
* @description
|
|
* The parsed result of a single ContentProtection element.
|
|
*
|
|
* @property {!Element} node
|
|
* The ContentProtection XML element.
|
|
* @property {string} schemeUri
|
|
* The scheme URI.
|
|
* @property {?string} keyId
|
|
* The default key ID, if present.
|
|
* @property {Array.<shaka.extern.InitDataOverride>} init
|
|
* The init data, if present. If there is no init data, it will be null. If
|
|
* this is non-null, there is at least one element.
|
|
*/
|
|
shaka.dash.ContentProtection.Element;
|
|
|
|
|
|
/**
|
|
* A map of scheme URI to key system name.
|
|
*
|
|
* @const {!Map.<string, string>}
|
|
* @private
|
|
*/
|
|
shaka.dash.ContentProtection.defaultKeySystems_ = new Map()
|
|
.set('urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b',
|
|
'org.w3.clearkey')
|
|
.set('urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed',
|
|
'com.widevine.alpha')
|
|
.set('urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95',
|
|
'com.microsoft.playready')
|
|
.set('urn:uuid:f239e769-efa3-4850-9c16-a903c6932efb',
|
|
'com.adobe.primetime');
|
|
|
|
/**
|
|
* A map of key system name to license server url parser.
|
|
*
|
|
* @const {!Map.<string, function(shaka.dash.ContentProtection.Element)>}
|
|
* @private
|
|
*/
|
|
shaka.dash.ContentProtection.licenseUrlParsers_ = new Map()
|
|
.set('com.widevine.alpha',
|
|
shaka.dash.ContentProtection.getWidevineLicenseUrl)
|
|
.set('com.microsoft.playready',
|
|
shaka.dash.ContentProtection.getPlayReadyLicenseUrl);
|
|
|
|
/**
|
|
* @const {string}
|
|
* @private
|
|
*/
|
|
shaka.dash.ContentProtection.MP4Protection_ =
|
|
'urn:mpeg:dash:mp4protection:2011';
|
|
|
|
|
|
/**
|
|
* @const {string}
|
|
* @private
|
|
*/
|
|
shaka.dash.ContentProtection.CencNamespaceUri_ = 'urn:mpeg:cenc:2013';
|