/**
* 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 DashJSError from './../vo/DashJSError';
import {HTTPRequest} from '../vo/metrics/HTTPRequest';
import EventBus from './../../core/EventBus';
import Events from './../../core/events/Events';
import Errors from './../../core/errors/Errors';
import FactoryMaker from '../../core/FactoryMaker';
import Debug from '../../core/Debug';
import URLUtils from '../utils/URLUtils';
const HTTP_TIMEOUT_MS = 5000;
const DEFAULT_MAXIMUM_ALLOWED_DRIFT = 100;
const DEFAULT_TIME_BETWEEN_SYNC_ATTEMPTS_ADJUSTMENT_FACTOR = 2;
const DEFAULT_BACKGROUND_ATTEMPTS = 2;
const DEFAULT_TIME_BETWEEN_SYNC_ATTEMPTS = 30;
const DEFAULT_MINIMUM_TIME_BETWEEN_BACKGROUND_SYNC_ATTEMPTS = 30;
const DEFAULT_MAXIMUM_TIME_BETWEEN_SYNC = 600;
const DEFAULT_MINIMUM_TIME_BETWEEN_SYNC = 2;
function TimeSyncController() {
const context = this.context;
const eventBus = EventBus(context).getInstance();
const urlUtils = URLUtils(context).getInstance();
let instance,
logger,
isSynchronizing,
isBackgroundSynchronizing,
settings,
handlers,
dashMetrics,
backgroundSyncTimeOffsets,
timingSources,
timeOfLastSync,
timeOfLastBackgroundSync,
lastOffset,
lastTimingSource,
internalTimeBetweenSyncAttempts,
errHandler,
baseURLController;
function setup() {
logger = Debug(context).getInstance().getLogger(instance);
eventBus.on(Events.ATTEMPT_BACKGROUND_SYNC, _onAttemptBackgroundSync, instance);
}
function setConfig(config) {
if (!config) return;
if (config.dashMetrics) {
dashMetrics = config.dashMetrics;
}
if (config.baseURLController) {
baseURLController = config.baseURLController;
}
if (config.errHandler) {
errHandler = config.errHandler;
}
if (config.settings) {
settings = config.settings;
}
}
function _resetInitialSettings() {
backgroundSyncTimeOffsets = [];
timingSources = [];
timeOfLastSync = null;
timeOfLastBackgroundSync = null;
lastTimingSource = null;
lastOffset = NaN;
isSynchronizing = false;
isBackgroundSynchronizing = false;
internalTimeBetweenSyncAttempts = settings.get().streaming.utcSynchronization.timeBetweenSyncAttempts;
}
/**
* Register the timing handler depending on the schemeIdUris. This method is called once when the StreamController is initialized
*/
function initialize() {
_resetInitialSettings();
// a list of known schemeIdUris and a method to call with @value
handlers = {
'urn:mpeg:dash:utc:http-head:2014': _httpHeadHandler,
'urn:mpeg:dash:utc:http-xsdate:2014': _httpHandler.bind(null, _xsdatetimeDecoder),
'urn:mpeg:dash:utc:http-iso:2014': _httpHandler.bind(null, _iso8601Decoder),
'urn:mpeg:dash:utc:direct:2014': _directHandler,
// some specs referencing early ISO23009-1 drafts incorrectly use
// 2012 in the URI, rather than 2014. support these for now.
'urn:mpeg:dash:utc:http-head:2012': _httpHeadHandler,
'urn:mpeg:dash:utc:http-xsdate:2012': _httpHandler.bind(null, _xsdatetimeDecoder),
'urn:mpeg:dash:utc:http-iso:2012': _httpHandler.bind(null, _iso8601Decoder),
'urn:mpeg:dash:utc:direct:2012': _directHandler,
// it isn't clear how the data returned would be formatted, and
// no public examples available so http-ntp not supported for now.
// presumably you would do an arraybuffer type xhr and decode the
// binary data returned but I would want to see a sample first.
'urn:mpeg:dash:utc:http-ntp:2014': _notSupportedHandler,
// not clear how this would be supported in javascript (in browser)
'urn:mpeg:dash:utc:ntp:2014': _notSupportedHandler,
'urn:mpeg:dash:utc:sntp:2014': _notSupportedHandler
};
}
/**
* Sync against a timing source. T
* @param {array} tSources
* @param {boolean} isDynamic
*/
function attemptSync(tSources, isDynamic) {
timingSources = tSources;
// Stop if we are already synchronizing
if (isSynchronizing) {
return;
}
// No synchronization required we can signal the completion immediately
if (!_shouldPerformSynchronization(isDynamic)) {
eventBus.trigger(Events.TIME_SYNCHRONIZATION_COMPLETED);
return;
}
isSynchronizing = true;
_attemptRecursiveSync();
}
/**
* Does a synchronization in the background in case the last offset should be verified or a 404 occurs
*/
function _onAttemptBackgroundSync() {
if (!settings.get().streaming.utcSynchronization.enabled || isSynchronizing || isBackgroundSynchronizing || !lastTimingSource || !lastTimingSource.value || !lastTimingSource.schemeIdUri || isNaN(lastOffset) || isNaN(settings.get().streaming.utcSynchronization.backgroundAttempts)) {
return;
}
if (timeOfLastBackgroundSync && ((Date.now() - timeOfLastBackgroundSync) / 1000) < DEFAULT_MINIMUM_TIME_BETWEEN_BACKGROUND_SYNC_ATTEMPTS) {
return;
}
backgroundSyncTimeOffsets = [];
isBackgroundSynchronizing = true;
const backgroundAttempts = !isNaN(settings.get().streaming.utcSynchronization.backgroundAttempts) ? settings.get().streaming.utcSynchronization.backgroundAttempts : DEFAULT_BACKGROUND_ATTEMPTS;
_attemptBackgroundSync(backgroundAttempts);
}
/**
* Perform a defined number of background attempts
* @param {number} attempts
* @private
*/
function _attemptBackgroundSync(attempts) {
try {
if (attempts <= 0) {
_completeBackgroundTimeSyncSequence();
return;
}
const deviceTimeBeforeSync = Date.now();
handlers[lastTimingSource.schemeIdUri](
lastTimingSource.value,
function (serverTime) {
// the timing source returned something useful
const deviceTimeAfterSync = Date.now();
const offset = _calculateOffset(deviceTimeBeforeSync, deviceTimeAfterSync, serverTime);
backgroundSyncTimeOffsets.push(offset);
_attemptBackgroundSync(attempts - 1);
},
function () {
_completeBackgroundTimeSyncSequence();
}
);
} catch (e) {
_completeBackgroundTimeSyncSequence();
}
}
/**
* Sync against a timing source. This method is called recursively if the time sync for the first entry in timingSources fails.
* @param {number} sourceIndex
*/
function _attemptRecursiveSync(sourceIndex = null) {
// if called with no sourceIndex, use zero (highest priority)
let index = sourceIndex || 0;
// the sources should be ordered in priority from the manifest.
// try each in turn, from the top, until either something
// sensible happens, or we run out of sources to try.
if (!timingSources || timingSources.length === 0 || index >= timingSources.length) {
_onComplete();
return;
}
let source = timingSources[index];
if (source) {
// check if there is a handler for this @schemeIdUri
if (handlers.hasOwnProperty(source.schemeIdUri)) {
// if so, call it with its @value
const deviceTimeBeforeSync = new Date().getTime();
handlers[source.schemeIdUri](
source.value,
function (serverTime) {
// the timing source returned something useful
const deviceTimeAfterSync = new Date().getTime();
const offset = _calculateOffset(deviceTimeBeforeSync, deviceTimeAfterSync, serverTime);
lastTimingSource = source;
_onComplete(offset);
},
function () {
// the timing source was probably uncontactable
// or returned something we can't use - try again
// with the remaining sources
_attemptRecursiveSync(index + 1);
}
);
} else {
// an unknown schemeIdUri must have been found
// try again with the remaining sources
_attemptRecursiveSync(index + 1);
}
} else {
// no valid time source could be found, just use device time
_onComplete();
}
}
/**
* Calculate the offset between client and server. Account for the roundtrip time
* @param {number} deviceTimeBeforeSync
* @param {number} deviceTimeAfterSync
* @param {number} serverTime
* @return {number}
* @private
*/
function _calculateOffset(deviceTimeBeforeSync, deviceTimeAfterSync, serverTime) {
const deviceReferenceTime = deviceTimeAfterSync - ((deviceTimeAfterSync - deviceTimeBeforeSync) / 2);
return serverTime - deviceReferenceTime;
}
/**
* Checks if a synchronization is required
* @param {boolean} isDynamic
* @return {boolean}
* @private
*/
function _shouldPerformSynchronization(isDynamic) {
try {
if (!isDynamic || !settings.get().streaming.utcSynchronization.enabled) {
return false;
}
const timeBetweenSyncAttempts = !isNaN(internalTimeBetweenSyncAttempts) ? internalTimeBetweenSyncAttempts : DEFAULT_TIME_BETWEEN_SYNC_ATTEMPTS;
if (!timeOfLastSync || !timeBetweenSyncAttempts || isNaN(timeBetweenSyncAttempts)) {
return true;
}
return ((Date.now() - timeOfLastSync) / 1000) >= timeBetweenSyncAttempts;
} catch (e) {
return true;
}
}
/**
* Callback after sync has been completed
* @param {number} offset
* @private
*/
function _onComplete(offset = NaN) {
let failed = isNaN(offset);
if (failed && settings.get().streaming.utcSynchronization.useManifestDateHeaderTimeSource) {
//Before falling back to binary search , check if date header exists on MPD. if so, use for a time source.
_checkForDateHeader();
} else {
_completeTimeSyncSequence(failed, offset);
}
}
/**
* Takes xsdatetime and returns milliseconds since UNIX epoch. May not be necessary as xsdatetime is very similar to ISO 8601 which is natively understood by javascript Date parser
* @param {string} xsdatetimeStr
* @return {number}
* @private
*/
function _alternateXsdatetimeDecoder(xsdatetimeStr) {
// taken from DashParser - should probably refactor both uses
const SECONDS_IN_MIN = 60;
const MINUTES_IN_HOUR = 60;
const MILLISECONDS_IN_SECONDS = 1000;
let datetimeRegex = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})(?::([0-9]*)(\.[0-9]*)?)?(?:([+\-])([0-9]{2})([0-9]{2}))?/;
let utcDate,
timezoneOffset;
let match = datetimeRegex.exec(xsdatetimeStr);
// If the string does not contain a timezone offset different browsers can interpret it either
// as UTC or as a local time so we have to parse the string manually to normalize the given date value for
// all browsers
utcDate = Date.UTC(
parseInt(match[1], 10),
parseInt(match[2], 10) - 1, // months start from zero
parseInt(match[3], 10),
parseInt(match[4], 10),
parseInt(match[5], 10),
(match[6] && (parseInt(match[6], 10) || 0)),
(match[7] && parseFloat(match[7]) * MILLISECONDS_IN_SECONDS) || 0
);
// If the date has timezone offset take it into account as well
if (match[9] && match[10]) {
timezoneOffset = parseInt(match[9], 10) * MINUTES_IN_HOUR + parseInt(match[10], 10);
utcDate += (match[8] === '+' ? -1 : +1) * timezoneOffset * SECONDS_IN_MIN * MILLISECONDS_IN_SECONDS;
}
return new Date(utcDate).getTime();
}
/**
* Try to use the built in parser, since xsdate is a constrained ISO8601 which is supported natively by Date.parse. if that fails, try a regex-based version used elsewhere in this application.
* @param {string} xsdatetimeStr
* @return {number}
*/
function _xsdatetimeDecoder(xsdatetimeStr) {
let parsedDate = Date.parse(xsdatetimeStr);
if (isNaN(parsedDate)) {
parsedDate = _alternateXsdatetimeDecoder(xsdatetimeStr);
}
return parsedDate;
}
/**
* Takes ISO 8601 timestamp and returns milliseconds since UNIX epoch
* @param {string} isoStr
* @return {number}
*/
function _iso8601Decoder(isoStr) {
return Date.parse(isoStr);
}
/**
* Takes RFC 1123 timestamp (which is same as ISO8601) and returns milliseconds since UNIX epoch
* @param {string} dateStr
* @return {number}
*/
function _rfc1123Decoder(dateStr) {
return Date.parse(dateStr);
}
/**
* Handler for unsupported scheme ids.
* @param {string} url
* @param {function} onSuccessCB
* @param {function} onFailureCB
* @private
*/
function _notSupportedHandler(url, onSuccessCB, onFailureCB) {
onFailureCB();
}
/**
* Direct handler
* @param {string} xsdatetimeStr
* @param {function} onSuccessCB
* @param {function} onFailureCB
*/
function _directHandler(xsdatetimeStr, onSuccessCB, onFailureCB) {
let time = _xsdatetimeDecoder(xsdatetimeStr);
if (!isNaN(time)) {
onSuccessCB(time);
return;
}
onFailureCB();
}
/**
* Generic http handler
* @param {function} decoder
* @param {string} url
* @param {function} onSuccessCB
* @param {function} onFailureCB
* @param {boolean} isHeadRequest
* @private
*/
function _httpHandler(decoder, url, onSuccessCB, onFailureCB, isHeadRequest) {
let oncomplete,
onload;
let complete = false;
let req = new XMLHttpRequest();
let verb = isHeadRequest ? HTTPRequest.HEAD : HTTPRequest.GET;
let urls = url.match(/\S+/g);
// according to ISO 23009-1, url could be a white-space
// separated list of URLs. just handle one at a time.
url = urls.shift();
oncomplete = function () {
if (complete) {
return;
}
// we only want to pass through here once per xhr,
// regardless of whether the load was successful.
complete = true;
// if there are more urls to try, call self.
if (urls.length) {
_httpHandler(decoder, urls.join(' '), onSuccessCB, onFailureCB, isHeadRequest);
} else {
onFailureCB();
}
};
onload = function () {
let time,
result;
if (req.status === 200) {
time = isHeadRequest ?
req.getResponseHeader('Date') :
req.response;
result = decoder(time);
// decoder returns NaN if non-standard input
if (!isNaN(result)) {
onSuccessCB(result);
complete = true;
}
}
};
if (urlUtils.isRelative(url)) {
// passing no path to resolve will return just MPD BaseURL/baseUri
const baseUrl = baseURLController.resolve();
if (baseUrl) {
url = urlUtils.resolve(url, baseUrl.url);
}
}
req.open(verb, url);
req.timeout = HTTP_TIMEOUT_MS || 0;
req.onload = onload;
req.onloadend = oncomplete;
req.send();
}
/**
* Handler for http-head schemeIdUri
* @param {string} url
* @param {function} onSuccessCB
* @param {function} onFailureCB
* @private
*/
function _httpHeadHandler(url, onSuccessCB, onFailureCB) {
_httpHandler(_rfc1123Decoder, url, onSuccessCB, onFailureCB, true);
}
/**
* Checks if a date header is present in the MPD response and calculates the offset based on the header
* @private
*/
function _checkForDateHeader() {
let dateHeaderValue = dashMetrics.getLatestMPDRequestHeaderValueByID('Date');
let dateHeaderTime = dateHeaderValue !== null ? new Date(dateHeaderValue).getTime() : Number.NaN;
if (!isNaN(dateHeaderTime)) {
const offsetToDeviceTimeMs = dateHeaderTime - Date.now();
_completeTimeSyncSequence(false, offsetToDeviceTimeMs);
} else {
_completeTimeSyncSequence(true);
}
}
/**
* Triggers the event to signal that the time synchronization was completed
* @param {boolean} failed
* @param {number} offset
* @private
*/
function _completeTimeSyncSequence(failed, offset) {
// Adjust the time of the next sync based on the drift between current offset and last offset
if (!isNaN(lastOffset) && !isNaN(offset) && !failed) {
_adjustTimeBetweenSyncAttempts(offset);
}
// Update the internal data
if (!failed && !isNaN(offset)) {
timeOfLastSync = Date.now();
isSynchronizing = false;
// if this is the first sync we are doing perform background syncs as well to confirm current offset
const shouldAttemptBackgroundSync = isNaN(lastOffset);
lastOffset = offset;
if (shouldAttemptBackgroundSync) {
_onAttemptBackgroundSync();
}
logger.debug(`Completed UTC sync. Setting client - server offset to ${offset}`);
}
if (failed) {
lastTimingSource = null;
isSynchronizing = false;
errHandler.error(new DashJSError(Errors.TIME_SYNC_FAILED_ERROR_CODE, Errors.TIME_SYNC_FAILED_ERROR_MESSAGE));
}
// Notify other classes
eventBus.trigger(Events.UPDATE_TIME_SYNC_OFFSET, {
offset: offset,
});
eventBus.trigger(Events.TIME_SYNCHRONIZATION_COMPLETED);
}
function _adjustTimeBetweenSyncAttempts(offset) {
try {
const isOffsetDriftWithinThreshold = _isOffsetDriftWithinThreshold(offset);
const timeBetweenSyncAttempts = !isNaN(internalTimeBetweenSyncAttempts) ? internalTimeBetweenSyncAttempts : DEFAULT_TIME_BETWEEN_SYNC_ATTEMPTS;
const timeBetweenSyncAttemptsAdjustmentFactor = !isNaN(settings.get().streaming.utcSynchronization.timeBetweenSyncAttemptsAdjustmentFactor) ? settings.get().streaming.utcSynchronization.timeBetweenSyncAttemptsAdjustmentFactor : DEFAULT_TIME_BETWEEN_SYNC_ATTEMPTS_ADJUSTMENT_FACTOR;
const maximumTimeBetweenSyncAttempts = !isNaN(settings.get().streaming.utcSynchronization.maximumTimeBetweenSyncAttempts) ? settings.get().streaming.utcSynchronization.maximumTimeBetweenSyncAttempts : DEFAULT_MAXIMUM_TIME_BETWEEN_SYNC;
const minimumTimeBetweenSyncAttempts = !isNaN(settings.get().streaming.utcSynchronization.minimumTimeBetweenSyncAttempts) ? settings.get().streaming.utcSynchronization.minimumTimeBetweenSyncAttempts : DEFAULT_MINIMUM_TIME_BETWEEN_SYNC;
let adjustedTimeBetweenSyncAttempts;
if (isOffsetDriftWithinThreshold) {
// The drift between the current offset and the last offset is within the allowed threshold. Increase sync time
adjustedTimeBetweenSyncAttempts = Math.min(timeBetweenSyncAttempts * timeBetweenSyncAttemptsAdjustmentFactor, maximumTimeBetweenSyncAttempts);
logger.debug(`Increasing timeBetweenSyncAttempts to ${adjustedTimeBetweenSyncAttempts}`);
} else {
// Drift between the current offset and the last offset is not within the allowed threshold. Decrease sync time
adjustedTimeBetweenSyncAttempts = Math.max(timeBetweenSyncAttempts / timeBetweenSyncAttemptsAdjustmentFactor, minimumTimeBetweenSyncAttempts);
logger.debug(`Decreasing timeBetweenSyncAttempts to ${adjustedTimeBetweenSyncAttempts}`);
}
internalTimeBetweenSyncAttempts = adjustedTimeBetweenSyncAttempts;
} catch (e) {
}
}
/**
* Callback after all background syncs have been completed.
* @private
*/
function _completeBackgroundTimeSyncSequence() {
if (!backgroundSyncTimeOffsets || backgroundSyncTimeOffsets.length === 0) {
return;
}
const averageOffset = backgroundSyncTimeOffsets.reduce((acc, curr) => {
return acc + curr;
}, 0) / backgroundSyncTimeOffsets.length;
if (!_isOffsetDriftWithinThreshold(averageOffset)) {
logger.debug(`Completed background UTC sync. Setting client - server offset to ${averageOffset}`);
lastOffset = averageOffset;
eventBus.trigger(Events.UPDATE_TIME_SYNC_OFFSET, {
offset: lastOffset
});
} else {
logger.debug(`Completed background UTC sync. Offset is within allowed threshold and is not adjusted.`);
}
isBackgroundSynchronizing = false;
timeOfLastBackgroundSync = Date.now();
}
function _isOffsetDriftWithinThreshold(offset) {
try {
if (isNaN(lastOffset)) {
return true;
}
const maxAllowedDrift = settings.get().streaming.utcSynchronization.maximumAllowedDrift && !isNaN(settings.get().streaming.utcSynchronization.maximumAllowedDrift) ? settings.get().streaming.utcSynchronization.maximumAllowedDrift : DEFAULT_MAXIMUM_ALLOWED_DRIFT;
const lowerBound = lastOffset - maxAllowedDrift;
const upperBound = lastOffset + maxAllowedDrift;
return offset >= lowerBound && offset <= upperBound;
} catch (e) {
return true;
}
}
function reset() {
_resetInitialSettings();
eventBus.off(Events.ATTEMPT_BACKGROUND_SYNC, _onAttemptBackgroundSync, instance);
}
instance = {
initialize,
attemptSync,
setConfig,
reset
};
setup();
return instance;
}
TimeSyncController.__dashjs_factory_name = 'TimeSyncController';
const factory = FactoryMaker.getSingletonFactory(TimeSyncController);
factory.HTTP_TIMEOUT_MS = HTTP_TIMEOUT_MS;
FactoryMaker.updateSingletonFactory(TimeSyncController.__dashjs_factory_name, factory);
export default factory;