offline_OfflineDownload.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.
 */
import OfflineConstants from './constants/OfflineConstants';
import OfflineStream from './OfflineStream';
import OfflineIndexDBManifestParser from './utils/OfflineIndexDBManifestParser';
import OfflineErrors from './errors/OfflineErrors';
import DashParser from '../dash/parser/DashParser';

function OfflineDownload(config) {
    config = config || {};

    const context = this.context;
    const manifestLoader = config.manifestLoader;
    const mediaPlayerModel = config.mediaPlayerModel;
    const abrController = config.abrController;
    const playbackController = config.playbackController;
    const adapter = config.adapter;
    const dashMetrics = config.dashMetrics;
    const timelineConverter = config.timelineConverter;
    const offlineStoreController = config.offlineStoreController;
    const manifestId = config.id;
    const eventBus = config.eventBus;
    const errHandler = config.errHandler;
    const events = config.events;
    const errors = config.errors;
    const settings = config.settings;
    const debug = config.debug;
    const manifestUpdater = config.manifestUpdater;
    const baseURLController = config.baseURLController;
    const segmentBaseController = config.segmentBaseController;
    const constants = config.constants;
    const dashConstants = config.dashConstants;
    const urlUtils = config.urlUtils;

    let instance,
        logger,
        _manifestURL,
        _offlineURL,
        _xmlManifest,
        _streams,
        _manifest,
        _isDownloadingStatus,
        _isComposed,
        _representationsToUpdate,
        _indexDBManifestParser,
        _progressionById,
        _progression,
        _status;


    function setup() {
        logger = debug.getLogger(instance);
        manifestUpdater.initialize();
        _streams = [];
        _isDownloadingStatus = false;
        _isComposed = false;
        _progressionById = {};
        _progression = 0;
        _status = undefined;
    }

    function getId() {
        return manifestId;
    }

    function getOfflineUrl() {
        return _offlineURL;
    }

    function getManifestUrl() {
        return _manifestURL;
    }

    function getStatus() {
        return _status;
    }

    function setInitialState(state) {
        _offlineURL = state.url;
        _progression = state.progress;
        _manifestURL = state.originalUrl;
        _status = state.status;
    }

    /**
     * Download a stream, from url of manifest
     * @param {string} url
     * @instance
     */
    function downloadFromUrl(url) {
        _manifestURL = url;
        _offlineURL = `${OfflineConstants.OFFLINE_SCHEME}://${manifestId}`;
        _status = OfflineConstants.OFFLINE_STATUS_CREATED;
        setupOfflineEvents();
        let offlineManifest = {
            'fragmentStore': manifestId,
            'status': _status,
            'manifestId': manifestId,
            'url': _offlineURL,
            'originalURL': url
        };
        return createOfflineManifest(offlineManifest);
    }

    function initDownload() {
        manifestLoader.load(_manifestURL);
        _isDownloadingStatus = true;
    }

    function setupOfflineEvents() {
        eventBus.on(events.MANIFEST_UPDATED, onManifestUpdated, instance);
        eventBus.on(events.ORIGINAL_MANIFEST_LOADED, onOriginalManifestLoaded, instance);
        setupIndexedDBEvents();
    }

    function setupIndexedDBEvents() {
        eventBus.on(events.ERROR, onError, instance);
    }

    function isDownloading() {
        return _isDownloadingStatus;
    }

    function onManifestUpdated(e) {
        if (_isComposed) {
            return;
        }
        if (!e.error) {
            try {
                _manifest = e.manifest;
            } catch (err) {
                _status = OfflineConstants.OFFLINE_STATUS_ERROR;
                errHandler.error({
                    code: OfflineErrors.OFFLINE_ERROR,
                    message: err.message,
                    data: {
                        id: manifestId,
                        status: _status
                    }
                });
            }
        }
    }

    function onDownloadingStarted(e) {
        if (e.id !== manifestId) {
            return;
        }
        if (!e.error && manifestId !== null) {
            _status = OfflineConstants.OFFLINE_STATUS_STARTED;
            offlineStoreController.setDownloadingStatus(manifestId, _status).then(function () {
                eventBus.trigger(events.OFFLINE_RECORD_STARTED, {
                    id: manifestId,
                    message: 'Downloading started for this stream !'
                });
            });
        } else {
            _status = OfflineConstants.OFFLINE_STATUS_ERROR;
            errHandler.error({
                code: OfflineErrors.OFFLINE_ERROR,
                message: 'Cannot start download ',
                data: {
                    id: manifestId,
                    status: _status,
                    error: e.error
                }
            });
        }
    }

    function OnStreamProgression(stream, downloaded, available) {

        _progressionById[stream.getStreamInfo().id] = {
            downloaded,
            available
        };

        let segments = 0;
        let allSegments = 0;
        let waitForAllProgress;
        for (var property in _progressionById) {
            if (_progressionById.hasOwnProperty(property)) {
                if (_progressionById[property] === null) {
                    waitForAllProgress = true;
                } else {
                    segments += _progressionById[property].downloaded;
                    allSegments += _progressionById[property].available;
                }
            }
        }

        if (!waitForAllProgress) {
            // all progression have been started, we can compute global progression
            _progression = segments / allSegments;

            // store progression
            offlineStoreController.getManifestById(manifestId)
                .then((item) => {
                    item.progress = _progression;
                    return updateOfflineManifest(item);
                });
        }
    }

    function onDownloadingFinished(e) {
        if (e.id !== manifestId) {
            return;
        }
        if (!e.error && manifestId !== null) {
            _status = OfflineConstants.OFFLINE_STATUS_FINISHED;
            offlineStoreController.setDownloadingStatus(manifestId, _status)
                .then(function () {
                    eventBus.trigger(events.OFFLINE_RECORD_FINISHED, {
                        id: manifestId,
                        message: 'Downloading has been successfully completed for this stream !'
                    });
                    resetDownload();
                });
        } else {
            _status = OfflineConstants.OFFLINE_STATUS_ERROR;
            errHandler.error({
                code: OfflineErrors.OFFLINE_ERROR,
                message: 'Error finishing download ',
                data: {
                    id: manifestId,
                    status: _status,
                    error: e.error
                }
            });
        }
    }

    function onManifestUpdateNeeded(e) {
        if (e.id !== manifestId) {
            return;
        }

        _representationsToUpdate = e.representations;

        if (_representationsToUpdate.length > 0) {
            _indexDBManifestParser.parse(_xmlManifest, _representationsToUpdate).then(function (parsedManifest) {
                if (parsedManifest !== null && manifestId !== null) {
                    offlineStoreController.getManifestById(manifestId)
                        .then((item) => {
                            item.manifest = parsedManifest;
                            return updateOfflineManifest(item);
                        })
                        .then(function () {
                            for (let i = 0, ln = _streams.length; i < ln; i++) {
                                _streams[i].startOfflineStreamProcessors();
                            }
                        });
                } else {
                    throw 'falling parsing offline manifest';
                }
            }).catch(function (err) {
                throw err;
            });
        }
    }

    function composeStreams() {
        try {
            adapter.updatePeriods(_manifest);
            baseURLController.initialize(_manifest);
            const streamsInfo = adapter.getStreamsInfo();
            if (streamsInfo.length === 0) {
                _status = OfflineConstants.OFFLINE_STATUS_ERROR;
                errHandler.error({
                    code: OfflineErrors.OFFLINE_ERROR,
                    message: 'Cannot download - no streams',
                    data: {
                        id: manifestId,
                        status: _status
                    }
                });
            }
            for (let i = 0, ln = streamsInfo.length; i < ln; i++) {
                const streamInfo = streamsInfo[i];
                let stream = OfflineStream(context).create({
                    id: manifestId,
                    callbacks: {
                        started: onDownloadingStarted,
                        progression: OnStreamProgression,
                        finished: onDownloadingFinished,
                        updateManifestNeeded: onManifestUpdateNeeded
                    },
                    constants: constants,
                    dashConstants: dashConstants,
                    eventBus: eventBus,
                    events: events,
                    errors: errors,
                    settings: settings,
                    debug: debug,
                    errHandler: errHandler,
                    mediaPlayerModel: mediaPlayerModel,
                    abrController: abrController,
                    playbackController: playbackController,
                    dashMetrics: dashMetrics,
                    baseURLController: baseURLController,
                    timelineConverter: timelineConverter,
                    adapter: adapter,
                    segmentBaseController: segmentBaseController,
                    offlineStoreController: offlineStoreController
                });
                _streams.push(stream);

                // initialize stream and get downloadable representations
                stream.initialize(streamInfo);
                _progressionById[streamInfo.id] = null;
            }
            _isComposed = true;
        } catch (e) {
            logger.info(e);
            _status = OfflineConstants.OFFLINE_STATUS_ERROR;
            errHandler.error({
                code: OfflineErrors.OFFLINE_ERROR,
                message: e.message,
                data: {
                    id: manifestId,
                    status: _status,
                    error: e.error
                }
            });
        }
    }

    function getMediaInfos() {
        _streams.forEach(stream => {
            stream.getMediaInfos();
        });
    }

    /**
     * Init databsse to store fragments
     * @param {number} manifestId
     * @instance
     */
    function createFragmentStore(manifestId) {
        return offlineStoreController.createFragmentStore(manifestId);
    }

    /**
     * Store in database the string representation of offline manifest (with only downloaded representations)
     * @param {object} offlineManifest
     * @instance
     */
    function createOfflineManifest(offlineManifest) {
        return offlineStoreController.createOfflineManifest(offlineManifest);
    }

    /**
     * Store in database the string representation of offline manifest (with only downloaded representations)
     * @param {object} offlineManifest
     * @instance
     */
    function updateOfflineManifest(offlineManifest) {
        return offlineStoreController.updateOfflineManifest(offlineManifest);
    }

    /**
     * Triggered when manifest is loaded from internet.
     * @param {Object[]} e
     */
    function onOriginalManifestLoaded(e) {
        // unregister form event
        eventBus.off(events.ORIGINAL_MANIFEST_LOADED, onOriginalManifestLoaded, instance);

        _xmlManifest = e.originalManifest;

        if (_manifest.type === dashConstants.DYNAMIC) {
            _status = OfflineConstants.OFFLINE_STATUS_ERROR;
            errHandler.error({
                code: OfflineErrors.OFFLINE_ERROR,
                message: 'Cannot handle DYNAMIC manifest',
                data: {
                    id: manifestId,
                    status: _status
                }
            });
            logger.error('Cannot handle DYNAMIC manifest');

            return;
        }

        if (_manifest.Period_asArray.length > 1) {
            _status = OfflineConstants.OFFLINE_STATUS_ERROR;
            errHandler.error({
                code: OfflineErrors.OFFLINE_ERROR,
                message: 'MultiPeriod manifest are not yet supported',
                data: {
                    id: manifestId,
                    status: _status
                }
            });
            logger.error('MultiPeriod manifest are not yet supported');

            return;
        }

        // save original manifest (for resume)

        // initialize offline streams
        composeStreams(_manifest);

        // get MediaInfos
        getMediaInfos();

        eventBus.trigger(events.STREAMS_COMPOSED);
    }

    function initializeAllMediasInfoList(selectedRepresentations) {
        for (let i = 0; i < _streams.length; i++) {
            _streams[i].initializeAllMediasInfoList(selectedRepresentations);
        }
    }

    function getSelectedRepresentations(mediaInfos) {
        let rep = {};
        rep[constants.VIDEO] = [];
        rep[constants.AUDIO] = [];
        rep[constants.TEXT] = [];

        // selectedRepresentations.video.forEach(item => {
        //     ret[constants.VIDEO].push(item.id);
        // });
        // selectedRepresentations.audio.forEach(item => {
        //     ret[constants.AUDIO].push(item.id);
        // });
        // selectedRepresentations.text.forEach(item => {
        //     ret[item.type].push(item.id);
        // });

        mediaInfos.forEach(mediaInfo => {
            mediaInfo.bitrateList.forEach(bitrate => {
                rep[mediaInfo.type].push(bitrate.id);
            });
        });
        return rep;
    }

    function startDownload(mediaInfos) {
        try {
            let rep = getSelectedRepresentations(mediaInfos);

            offlineStoreController.saveSelectedRepresentations(manifestId, rep)
                .then(() => {
                    return createFragmentStore(manifestId);
                })
                .then(() => {
                    return generateOfflineManifest(rep);
                })
                .then(function () {
                    initializeAllMediasInfoList(rep);
                });
        } catch (err) {
            _status = OfflineConstants.OFFLINE_STATUS_ERROR;
            errHandler.error({
                code: OfflineErrors.OFFLINE_ERROR,
                message: err.message,
                data: {
                    id: manifestId,
                    status: _status
                }
            });
        }
    }

    /**
     * Create the parser used to convert original manifest in offline manifest
     * Creates a JSON object that will be stored in database
     * @param {Object[]} selectedRepresentations
     * @instance
     */
    function generateOfflineManifest(selectedRepresentations) {
        _indexDBManifestParser = OfflineIndexDBManifestParser(context).create({
            manifestId: manifestId,
            allMediaInfos: selectedRepresentations,
            debug: debug,
            dashConstants: dashConstants,
            constants: constants,
            urlUtils: urlUtils
        });

        return _indexDBManifestParser.parse(_xmlManifest).then(function (parsedManifest) {
            if (parsedManifest !== null) {
                return offlineStoreController.getManifestById(manifestId)
                    .then((item) => {
                        item.originalURL = _manifest.url;
                        item.originalManifest = _xmlManifest;
                        item.manifest = parsedManifest;
                        return updateOfflineManifest(item);
                    });
            } else {
                return Promise.reject('falling parsing offline manifest');
            }
        }).catch(function (err) {
            return Promise.reject(err);
        });
    }

    /**
     * Stops downloading of fragments
     * @instance
     */
    function stopDownload() {
        if (manifestId !== null && isDownloading()) {
            for (let i = 0, ln = _streams.length; i < ln; i++) {
                _streams[i].stopOfflineStreamProcessors();
            }

            // remove streams
            _streams = [];

            _isComposed = false;

            _status = OfflineConstants.OFFLINE_STATUS_STOPPED;
            // update status
            offlineStoreController.setDownloadingStatus(manifestId, _status).then(function () {
                eventBus.trigger(events.OFFLINE_RECORD_STOPPED, {
                    sender: this,
                    id: manifestId,
                    status: _status,
                    message: 'Downloading has been stopped for this stream !'
                });
                _isDownloadingStatus = false;
            });
        }
    }

    /**
     * Delete an offline manifest (and all of its data)
     * @instance
     */
    function deleteDownload() {
        stopDownload();
    }

    /**
     * Resume download of a stream
     * @instance
     */
    function resumeDownload() {
        if (isDownloading()) {
            return;
        }

        _isDownloadingStatus = true;

        let selectedRepresentations;

        offlineStoreController.getManifestById(manifestId)
            .then((item) => {
                let parser = DashParser(context).create({ debug: debug });
                _manifest = parser.parse(item.originalManifest);

                composeStreams(_manifest);

                selectedRepresentations = item.selected;

                eventBus.trigger(events.STREAMS_COMPOSED);

                return createFragmentStore(manifestId);
            })
            .then(() => {
                initializeAllMediasInfoList(selectedRepresentations);
            });
    }

    /**
     * Compute the progression of download
     * @instance
     */
    function getDownloadProgression() {
        return Math.round(_progression * 100);
    }

    /**
     * Reset events listeners
     * @instance
     */
    function resetDownload() {
        for (let i = 0, ln = _streams.length; i < ln; i++) {
            _streams[i].reset();
        }
        _indexDBManifestParser = null;
        _isDownloadingStatus = false;
        _streams = [];
        eventBus.off(events.MANIFEST_UPDATED, onManifestUpdated, instance);
        eventBus.off(events.ORIGINAL_MANIFEST_LOADED, onOriginalManifestLoaded, instance);
        resetIndexedDBEvents();
    }

    function onError(e) {
        if (e.error.code === OfflineErrors.INDEXEDDB_QUOTA_EXCEED_ERROR ||
            e.error.code === OfflineErrors.INDEXEDDB_INVALID_STATE_ERROR) {
            stopDownload();
        }
    }

    function resetIndexedDBEvents() {
        eventBus.on(events.ERROR, onError, instance);
    }

    /**
     * Reset
     * @instance
     */
    function reset() {
        if (isDownloading()) {
            resetDownload();
        }
        baseURLController.reset();
        manifestUpdater.reset();
    }

    instance = {
        reset: reset,
        getId: getId,
        getOfflineUrl: getOfflineUrl,
        getManifestUrl: getManifestUrl,
        getStatus: getStatus,
        setInitialState: setInitialState,
        initDownload: initDownload,
        downloadFromUrl: downloadFromUrl,
        startDownload: startDownload,
        stopDownload: stopDownload,
        resumeDownload: resumeDownload,
        deleteDownload: deleteDownload,
        getDownloadProgression: getDownloadProgression,
        isDownloading: isDownloading,
        resetDownload: resetDownload
    };

    setup();

    return instance;
}

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