streaming_protection_models_ProtectionModel_21Jan2015.js

/**
 * The copyright in this software is being made available under the BSD License,
 * included below. This software may be subject to other third party and contributor
 * rights, including patent rights, and no such rights are granted under this license.
 *
 * Copyright (c) 2013, Dash Industry Forum.
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification,
 * are permitted provided that the following conditions are met:
 *  * Redistributions of source code must retain the above copyright notice, this
 *  list of conditions and the following disclaimer.
 *  * Redistributions in binary form must reproduce the above copyright notice,
 *  this list of conditions and the following disclaimer in the documentation and/or
 *  other materials provided with the distribution.
 *  * Neither the name of Dash Industry Forum nor the names of its
 *  contributors may be used to endorse or promote products derived from this software
 *  without specific prior written permission.
 *
 *  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
 *  EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 *  WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
 *  IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
 *  INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
 *  NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 *  PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
 *  WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 *  POSSIBILITY OF SUCH DAMAGE.
 */

/**
 * Most recent EME implementation
 *
 * Implemented by Google Chrome v36+ (Windows, OSX, Linux)
 *
 * @implements ProtectionModel
 * @class
 */
import ProtectionKeyController from '../controllers/ProtectionKeyController';
import NeedKey from '../vo/NeedKey';
import ProtectionErrors from '../errors/ProtectionErrors';
import DashJSError from '../../vo/DashJSError';
import KeyMessage from '../vo/KeyMessage';
import KeySystemAccess from '../vo/KeySystemAccess';
import ProtectionConstants from '../../constants/ProtectionConstants';

const SYSTEM_STRING_PRIORITY = {};
SYSTEM_STRING_PRIORITY[ProtectionConstants.PLAYREADY_KEYSTEM_STRING] = [ProtectionConstants.PLAYREADY_KEYSTEM_STRING, ProtectionConstants.PLAYREADY_RECOMMENDATION_KEYSTEM_STRING];
SYSTEM_STRING_PRIORITY[ProtectionConstants.WIDEVINE_KEYSTEM_STRING] = [ProtectionConstants.WIDEVINE_KEYSTEM_STRING];
SYSTEM_STRING_PRIORITY[ProtectionConstants.CLEARKEY_KEYSTEM_STRING] = [ProtectionConstants.CLEARKEY_KEYSTEM_STRING];

function ProtectionModel_21Jan2015(config) {

    config = config || {};
    const context = this.context;
    const eventBus = config.eventBus;//Need to pass in here so we can use same instance since this is optional module
    const events = config.events;
    const debug = config.debug;

    let instance,
        logger,
        keySystem,
        videoElement,
        mediaKeys,
        sessions,
        eventHandler,
        protectionKeyController;

    function setup() {
        logger = debug.getLogger(instance);
        keySystem = null;
        videoElement = null;
        mediaKeys = null;
        sessions = [];
        protectionKeyController = ProtectionKeyController(context).getInstance();
        eventHandler = createEventHandler();
    }

    function reset() {
        const numSessions = sessions.length;
        let session;

        if (numSessions !== 0) {
            // Called when we are done closing a session.  Success or fail
            const done = function (session) {
                removeSession(session);
                if (sessions.length === 0) {
                    if (videoElement) {
                        videoElement.removeEventListener('encrypted', eventHandler);
                        videoElement.setMediaKeys(null).then(function () {
                            eventBus.trigger(events.TEARDOWN_COMPLETE);
                        });
                    } else {
                        eventBus.trigger(events.TEARDOWN_COMPLETE);
                    }
                }
            };
            for (let i = 0; i < numSessions; i++) {
                session = sessions[i];
                (function (s) {
                    _closeKeySessionInternal(session)
                    done(s);
                })(session);
            }
        } else {
            eventBus.trigger(events.TEARDOWN_COMPLETE);
        }
    }

    function stop() {
        // Close and remove not usable sessions
        let session;
        for (let i = 0; i < sessions.length; i++) {
            session = sessions[i];
            if (!session.getUsable()) {
                _closeKeySessionInternal(session)
                removeSession(session);
            }
        }
    }

    function getAllInitData() {
        const retVal = [];
        for (let i = 0; i < sessions.length; i++) {
            if (sessions[i].initData) {
                retVal.push(sessions[i].initData);
            }
        }
        return retVal;
    }

    function getSessions() {
        return sessions;
    }

    function requestKeySystemAccess(ksConfigurations) {
        return new Promise((resolve, reject) => {
            _requestKeySystemAccessInternal(ksConfigurations, 0, resolve, reject);
        })
    }

    /**
     * Initializes access to a key system. Once we found a valid configuration we get a mediaKeySystemAccess object
     * @param ksConfigurations
     * @param idx
     * @param resolve
     * @param reject
     * @private
     */
    function _requestKeySystemAccessInternal(ksConfigurations, idx, resolve, reject) {

        // In case requestMediaKeySystemAccess is not available we can not proceed and dispatch an error
        if (navigator.requestMediaKeySystemAccess === undefined ||
            typeof navigator.requestMediaKeySystemAccess !== 'function') {
            const msg = 'Insecure origins are not allowed';
            eventBus.trigger(events.KEY_SYSTEM_ACCESS_COMPLETE, { error: msg });
            reject({ error: msg });
            return;
        }

        // If a systemStringPriority is defined by the application we use these values. Otherwise we use the default system string
        // This is useful for DRM systems such as Playready for which multiple system strings are possible for instance com.microsoft.playready and com.microsoft.playready.recommendation
        const protDataSystemStringPriority = ksConfigurations[idx].protData && ksConfigurations[idx].protData.systemStringPriority ? ksConfigurations[idx].protData.systemStringPriority : null;
        const configs = ksConfigurations[idx].configs;
        const currentKeySystem = ksConfigurations[idx].ks;
        let systemString = currentKeySystem.systemString;

        // Use the default values in case no values are provided by the application
        const systemStringsToApply = protDataSystemStringPriority ? protDataSystemStringPriority : SYSTEM_STRING_PRIORITY[systemString] ? SYSTEM_STRING_PRIORITY[systemString] : [systemString];

        // Check all the available system strings and the available configurations for support
        _checkAccessForKeySystem(systemStringsToApply, configs)
            .then((mediaKeySystemAccess) => {
                const configuration = (typeof mediaKeySystemAccess.getConfiguration === 'function') ?
                    mediaKeySystemAccess.getConfiguration() : null;
                const keySystemAccess = new KeySystemAccess(currentKeySystem, configuration);

                keySystemAccess.mksa = mediaKeySystemAccess;
                eventBus.trigger(events.KEY_SYSTEM_ACCESS_COMPLETE, { data: keySystemAccess });
                resolve({ data: keySystemAccess });
            })
            .catch((e) => {
                if (idx + 1 < ksConfigurations.length) {
                    _requestKeySystemAccessInternal(ksConfigurations, idx + 1, resolve, reject);
                } else {
                    const errorMessage = 'Key system access denied! ';
                    eventBus.trigger(events.KEY_SYSTEM_ACCESS_COMPLETE, { error: errorMessage + e.message });
                    reject({ error: errorMessage + e.message });
                }
            })
    }

    /**
     * For a specific key system: Iterate over the possible system strings and resolve once a valid configuration was found
     * @param {array} systemStringsToApply
     * @param {object} configs
     * @return {Promise}
     * @private
     */
    function _checkAccessForKeySystem(systemStringsToApply, configs) {
        return new Promise((resolve, reject) => {
            _checkAccessForSystemStrings(systemStringsToApply, configs, 0, resolve, reject);
        })
    }

    /**
     * Recursively iterate over the possible system strings until a supported configuration is found or we ran out of options
     * @param {array} systemStringsToApply
     * @param {object} configs
     * @param {number} idx
     * @param {function} resolve
     * @param {function} reject
     * @private
     */
    function _checkAccessForSystemStrings(systemStringsToApply, configs, idx, resolve, reject) {
        const systemString = systemStringsToApply[idx];

        logger.debug(`Requesting key system access for system string ${systemString}`);

        navigator.requestMediaKeySystemAccess(systemString, configs)
            .then((mediaKeySystemAccess) => {
                mediaKeySystemAccess.selectedSystemString = systemString;
                resolve(mediaKeySystemAccess);
            })
            .catch((e) => {
                if (idx + 1 < systemStringsToApply.length) {
                    _checkAccessForSystemStrings(systemStringsToApply, configs, idx + 1, resolve, reject);
                } else {
                    reject(e);
                }
            });
    }

    /**
     * Selects a key system by creating the mediaKeys and adding them to the video element
     * @param keySystemAccess
     * @return {Promise<unknown>}
     */
    function selectKeySystem(keySystemAccess) {
        return new Promise((resolve, reject) => {
            keySystemAccess.mksa.createMediaKeys()
                .then((mkeys) => {
                    keySystem = keySystemAccess.keySystem;
                    mediaKeys = mkeys;
                    if (videoElement) {
                        return videoElement.setMediaKeys(mediaKeys)
                    } else {
                        return Promise.resolve();
                    }
                })
                .then(() => {
                    resolve(keySystem);
                })
                .catch(function () {
                    reject({ error: 'Error selecting keys system (' + keySystemAccess.keySystem.systemString + ')! Could not create MediaKeys -- TODO' });
                });
        })
    }

    function setMediaElement(mediaElement) {
        if (videoElement === mediaElement)
            return;

        // Replacing the previous element
        if (videoElement) {
            videoElement.removeEventListener('encrypted', eventHandler);
            if (videoElement.setMediaKeys) {
                videoElement.setMediaKeys(null);
            }
        }

        videoElement = mediaElement;

        // Only if we are not detaching from the existing element
        if (videoElement) {
            videoElement.addEventListener('encrypted', eventHandler);
            if (videoElement.setMediaKeys && mediaKeys) {
                videoElement.setMediaKeys(mediaKeys);
            }
        }
    }

    function setServerCertificate(serverCertificate) {
        if (!keySystem || !mediaKeys) {
            throw new Error('Can not set server certificate until you have selected a key system');
        }
        mediaKeys.setServerCertificate(serverCertificate).then(function () {
            logger.info('DRM: License server certificate successfully updated.');
            eventBus.trigger(events.SERVER_CERTIFICATE_UPDATED);
        }).catch(function (error) {
            eventBus.trigger(events.SERVER_CERTIFICATE_UPDATED, { error: new DashJSError(ProtectionErrors.SERVER_CERTIFICATE_UPDATED_ERROR_CODE, ProtectionErrors.SERVER_CERTIFICATE_UPDATED_ERROR_MESSAGE + error.name) });
        });
    }

    /**
     * Create a key session, a session token and initialize a request by calling generateRequest
     * @param ksInfo
     */
    function createKeySession(ksInfo) {
        if (!keySystem || !mediaKeys) {
            throw new Error('Can not create sessions until you have selected a key system');
        }

        const session = mediaKeys.createSession(ksInfo.sessionType);
        const sessionToken = createSessionToken(session, ksInfo);


        // The "keyids" type is used for Clearkey when keys are provided directly in the protection data and a request to a license server is not needed
        const dataType = keySystem.systemString === ProtectionConstants.CLEARKEY_KEYSTEM_STRING && (ksInfo.initData || (ksInfo.protData && ksInfo.protData.clearkeys)) ? ProtectionConstants.INITIALIZATION_DATA_TYPE_KEYIDS : ProtectionConstants.INITIALIZATION_DATA_TYPE_CENC;

        session.generateRequest(dataType, ksInfo.initData).then(function () {
            logger.debug('DRM: Session created.  SessionID = ' + sessionToken.getSessionId());
            eventBus.trigger(events.KEY_SESSION_CREATED, { data: sessionToken });
        }).catch(function (error) {
            removeSession(sessionToken);
            eventBus.trigger(events.KEY_SESSION_CREATED, {
                data: null,
                error: new DashJSError(ProtectionErrors.KEY_SESSION_CREATED_ERROR_CODE, ProtectionErrors.KEY_SESSION_CREATED_ERROR_MESSAGE + 'Error generating key request -- ' + error.name)
            });
        });
    }

    function updateKeySession(sessionToken, message) {
        const session = sessionToken.session;

        // Send our request to the key session
        if (protectionKeyController.isClearKey(keySystem)) {
            message = message.toJWK();
        }
        session.update(message)
            .then(() => {
                eventBus.trigger(events.KEY_SESSION_UPDATED);
            })
            .catch(function (error) {
                eventBus.trigger(events.KEY_ERROR, { error: new DashJSError(ProtectionErrors.MEDIA_KEYERR_CODE, 'Error sending update() message! ' + error.name, sessionToken) });
            });
    }

    function loadKeySession(ksInfo) {
        if (!keySystem || !mediaKeys) {
            throw new Error('Can not load sessions until you have selected a key system');
        }

        const sessionId = ksInfo.sessionId;

        // Check if session Id is not already loaded or loading
        for (let i = 0; i < sessions.length; i++) {
            if (sessionId === sessions[i].sessionId) {
                logger.warn('DRM: Ignoring session ID because we have already seen it!');
                return;
            }
        }

        const session = mediaKeys.createSession(ksInfo.sessionType);
        const sessionToken = createSessionToken(session, ksInfo);

        // Load persisted session data into our newly created session object
        session.load(sessionId).then(function (success) {
            if (success) {
                logger.debug('DRM: Session loaded.  SessionID = ' + sessionToken.getSessionId());
                eventBus.trigger(events.KEY_SESSION_CREATED, { data: sessionToken });
            } else {
                removeSession(sessionToken);
                eventBus.trigger(events.KEY_SESSION_CREATED, {
                    data: null,
                    error: new DashJSError(ProtectionErrors.KEY_SESSION_CREATED_ERROR_CODE, ProtectionErrors.KEY_SESSION_CREATED_ERROR_MESSAGE + 'Could not load session! Invalid Session ID (' + sessionId + ')')
                });
            }
        }).catch(function (error) {
            removeSession(sessionToken);
            eventBus.trigger(events.KEY_SESSION_CREATED, {
                data: null,
                error: new DashJSError(ProtectionErrors.KEY_SESSION_CREATED_ERROR_CODE, ProtectionErrors.KEY_SESSION_CREATED_ERROR_MESSAGE + 'Could not load session (' + sessionId + ')! ' + error.name)
            });
        });
    }

    function removeKeySession(sessionToken) {
        const session = sessionToken.session;

        session.remove().then(function () {
            logger.debug('DRM: Session removed.  SessionID = ' + sessionToken.getSessionId());
            eventBus.trigger(events.KEY_SESSION_REMOVED, { data: sessionToken.getSessionId() });
        }, function (error) {
            eventBus.trigger(events.KEY_SESSION_REMOVED, {
                data: null,
                error: 'Error removing session (' + sessionToken.getSessionId() + '). ' + error.name
            });

        });
    }

    function closeKeySession(sessionToken) {
        // Send our request to the key session
        _closeKeySessionInternal(sessionToken).catch(function (error) {
            removeSession(sessionToken);
            eventBus.trigger(events.KEY_SESSION_CLOSED, {
                data: null,
                error: 'Error closing session (' + sessionToken.getSessionId() + ') ' + error.name
            });
        });
    }

    function _closeKeySessionInternal(sessionToken) {
        if (!sessionToken || !sessionToken.session) {
            return Promise.resolve;
        }
        const session = sessionToken.session;

        // Remove event listeners
        session.removeEventListener('keystatuseschange', sessionToken);
        session.removeEventListener('message', sessionToken);

        // Send our request to the key session
        return session.close();
    }

    // This is our main event handler for all desired HTMLMediaElement events
    // related to EME.  These events are translated into our API-independent
    // versions of the same events
    function createEventHandler() {
        return {
            handleEvent: function (event) {
                switch (event.type) {
                    case 'encrypted':
                        if (event.initData) {
                            let initData = ArrayBuffer.isView(event.initData) ? event.initData.buffer : event.initData;
                            eventBus.trigger(events.NEED_KEY, { key: new NeedKey(initData, event.initDataType) });
                        }
                        break;
                }
            }
        };
    }

    function removeSession(token) {
        // Remove from our session list
        for (let i = 0; i < sessions.length; i++) {
            if (sessions[i] === token) {
                sessions.splice(i, 1);
                break;
            }
        }
    }

    function parseKeyStatus(args) {
        // Edge and Chrome implement different version of keystatues, param are not on same order
        let status, keyId;
        if (args && args.length > 0) {
            if (args[0]) {
                if (typeof args[0] === 'string') {
                    status = args[0];
                } else {
                    keyId = args[0];
                }
            }

            if (args[1]) {
                if (typeof args[1] === 'string') {
                    status = args[1];
                } else {
                    keyId = args[1];
                }
            }
        }
        return {
            status: status,
            keyId: keyId
        };
    }

    // Function to create our session token objects which manage the EME
    // MediaKeySession and session-specific event handler
    function createSessionToken(session, ksInfo) {
        const token = { // Implements SessionToken
            session: session,
            keyId: ksInfo.keyId,
            initData: ksInfo.initData,
            sessionId: ksInfo.sessionId,
            sessionType: ksInfo.sessionType,

            // This is our main event handler for all desired MediaKeySession events
            // These events are translated into our API-independent versions of the
            // same events
            handleEvent: function (event) {
                switch (event.type) {
                    case 'keystatuseschange':
                        eventBus.trigger(events.KEY_STATUSES_CHANGED, { data: this });
                        event.target.keyStatuses.forEach(function () {
                            let keyStatus = parseKeyStatus(arguments);
                            switch (keyStatus.status) {
                                case 'expired':
                                    eventBus.trigger(events.INTERNAL_KEY_STATUS_CHANGED, { error: new DashJSError(ProtectionErrors.KEY_STATUS_CHANGED_EXPIRED_ERROR_CODE, ProtectionErrors.KEY_STATUS_CHANGED_EXPIRED_ERROR_MESSAGE) });
                                    break;
                                default:
                                    eventBus.trigger(events.INTERNAL_KEY_STATUS_CHANGED, keyStatus);
                                    break;
                            }
                        });
                        break;

                    case 'message':
                        let message = ArrayBuffer.isView(event.message) ? event.message.buffer : event.message;
                        eventBus.trigger(events.INTERNAL_KEY_MESSAGE, { data: new KeyMessage(this, message, undefined, event.messageType) });
                        break;
                }
            },

            getKeyId: function () {
                return this.keyId;
            },

            getSessionId: function () {
                return session.sessionId;
            },

            getSessionType: function () {
                return this.sessionType;
            },

            getExpirationTime: function () {
                return session.expiration;
            },

            getKeyStatuses: function () {
                return session.keyStatuses;
            },

            getUsable: function () {
                let usable = false;
                session.keyStatuses.forEach(function () {
                    let keyStatus = parseKeyStatus(arguments);
                    if (keyStatus.status === 'usable') {
                        usable = true;
                    }
                });
                return usable;
            }
        };

        // Add all event listeners
        session.addEventListener('keystatuseschange', token);
        session.addEventListener('message', token);

        // Register callback for session closed Promise
        session.closed.then(() => {
            removeSession(token);
            logger.debug('DRM: Session closed.  SessionID = ' + token.getSessionId());
            eventBus.trigger(events.KEY_SESSION_CLOSED, { data: token.getSessionId() });
        });

        // Add to our session list
        sessions.push(token);

        return token;
    }

    instance = {
        getAllInitData,
        getSessions,
        requestKeySystemAccess,
        selectKeySystem,
        setMediaElement,
        setServerCertificate,
        createKeySession,
        updateKeySession,
        loadKeySession,
        removeKeySession,
        closeKeySession,
        stop,
        reset
    };

    setup();

    return instance;
}

ProtectionModel_21Jan2015.__dashjs_factory_name = 'ProtectionModel_21Jan2015';
export default dashjs.FactoryMaker.getClassFactory(ProtectionModel_21Jan2015); /* jshint ignore:line */