/** * @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.DashParser'); goog.require('goog.asserts'); goog.require('shaka.dash.ContentProtection'); goog.require('shaka.dash.MpdUtils'); goog.require('shaka.dash.SegmentBase'); goog.require('shaka.dash.SegmentList'); goog.require('shaka.dash.SegmentTemplate'); goog.require('shaka.log'); goog.require('shaka.media.DrmEngine'); goog.require('shaka.media.ManifestParser'); goog.require('shaka.media.PresentationTimeline'); goog.require('shaka.media.SegmentReference'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.text.TextEngine'); goog.require('shaka.util.Error'); goog.require('shaka.util.Functional'); goog.require('shaka.util.LanguageUtils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.OperationManager'); goog.require('shaka.util.StringUtils'); goog.require('shaka.util.XmlUtils'); /** * Creates a new DASH parser. * * @struct * @constructor * @implements {shakaExtern.ManifestParser} * @export */ shaka.dash.DashParser = function() { /** @private {?shakaExtern.ManifestConfiguration} */ this.config_ = null; /** @private {?shakaExtern.ManifestParser.PlayerInterface} */ this.playerInterface_ = null; /** @private {!Array.} */ this.manifestUris_ = []; /** @private {?shakaExtern.Manifest} */ this.manifest_ = null; /** @private {!Array.} */ this.periodIds_ = []; /** @private {number} */ this.globalId_ = 1; /** * A map of IDs to SegmentIndex objects. * ID: Period@id,AdaptationSet@id,@Representation@id * e.g.: '1,5,23' * @private {!Object.} */ this.segmentIndexMap_ = {}; /** * The update period in seconds, or 0 for no updates. * @private {number} */ this.updatePeriod_ = 0; /** * The maximum duration, in seconds, of any update so far. * This is to mitigate issues caused by slow parsing on embedded devices. * @private {number} */ this.maxUpdateDuration_ = 0; /** @private {?number} */ this.updateTimer_ = null; /** @private {!shaka.util.OperationManager} */ this.operationManager_ = new shaka.util.OperationManager(); }; /** * Contains the minimum amount of time, in seconds, between manifest update * requests. * * @private * @const {number} */ shaka.dash.DashParser.MIN_UPDATE_PERIOD_ = 3; /** * @typedef { * !function(!Array., ?number, ?number):!Promise. * } */ shaka.dash.DashParser.RequestInitSegmentCallback; /** * @typedef {{ * segmentBase: Element, * segmentList: Element, * segmentTemplate: Element, * baseUris: !Array., * width: (number|undefined), * height: (number|undefined), * contentType: string, * mimeType: string, * codecs: string, * frameRate: (number|undefined), * containsEmsgBoxes: boolean, * id: string, * numChannels: ?number * }} * * @description * A collection of elements and properties which are inherited across levels * of a DASH manifest. * * @property {Element} segmentBase * The XML node for SegmentBase. * @property {Element} segmentList * The XML node for SegmentList. * @property {Element} segmentTemplate * The XML node for SegmentTemplate. * @property {!Array.} baseUris * An array of absolute base URIs for the frame. * @property {(number|undefined)} width * The inherited width value. * @property {(number|undefined)} height * The inherited height value. * @property {string} contentType * The inherited media type. * @property {string} mimeType * The inherited MIME type value. * @property {string} codecs * The inherited codecs value. * @property {(number|undefined)} frameRate * The inherited framerate value. * @property {boolean} containsEmsgBoxes * Whether there are 'emsg' boxes. * @property {string} id * The ID of the element. * @property {?number} numChannels * The number of audio channels, or null if unknown. */ shaka.dash.DashParser.InheritanceFrame; /** * @typedef {{ * dynamic: boolean, * presentationTimeline: !shaka.media.PresentationTimeline, * period: ?shaka.dash.DashParser.InheritanceFrame, * periodInfo: ?shaka.dash.DashParser.PeriodInfo, * adaptationSet: ?shaka.dash.DashParser.InheritanceFrame, * representation: ?shaka.dash.DashParser.InheritanceFrame, * bandwidth: number, * indexRangeWarningGiven: boolean * }} * * @description * Contains context data for the streams. * * @property {boolean} dynamic * True if the MPD is dynamic (not all segments available at once) * @property {!shaka.media.PresentationTimeline} presentationTimeline * The PresentationTimeline. * @property {?shaka.dash.DashParser.InheritanceFrame} period * The inheritance from the Period element. * @property {?shaka.dash.DashParser.PeriodInfo} periodInfo * The Period info for the current Period. * @property {?shaka.dash.DashParser.InheritanceFrame} adaptationSet * The inheritance from the AdaptationSet element. * @property {?shaka.dash.DashParser.InheritanceFrame} representation * The inheritance from the Representation element. * @property {number} bandwidth * The bandwidth of the Representation, or zero if missing. * @property {boolean} indexRangeWarningGiven * True if the warning about SegmentURL@indexRange has been printed. */ shaka.dash.DashParser.Context; /** * @typedef {{ * start: number, * duration: ?number, * node: !Element, * index: number, * isLastPeriod: boolean * }} * * @description * Contains information about a Period element. * * @property {number} start * The start time of the period. * @property {?number} duration * The duration of the period; or null if the duration is not given. This * will be non-null for all periods except the last. * @property {!Element} node * The XML Node for the Period. * @property {number} index * The 0-base index of this Period within the manifest. * @property {boolean} isLastPeriod * Whether this Period is the last one in the manifest. */ shaka.dash.DashParser.PeriodInfo; /** * @typedef {{ * id: string, * contentType: ?string, * language: string, * main: boolean, * streams: !Array., * drmInfos: !Array., * trickModeFor: ?string, * representationIds: !Array. * }} * * @description * Contains information about an AdaptationSet element. * * @property {string} id * The unique ID of the adaptation set. * @property {?string} contentType * The content type of the AdaptationSet. * @property {string} language * The language of the AdaptationSet. * @property {boolean} main * Whether the AdaptationSet has the 'main' type. * @property {!Array.} streams * The streams this AdaptationSet contains. * @property {!Array.} drmInfos * The DRM info for the AdaptationSet. * @property {?string} trickModeFor * If non-null, this AdaptationInfo represents trick mode tracks. This * property is the ID of the normal AdaptationSet these tracks should be * associated with. * @property {!Array.} representationIds * An array of the IDs of the Representations this AdaptationSet contains. */ shaka.dash.DashParser.AdaptationInfo; /** * @typedef {{ * createSegmentIndex: shakaExtern.CreateSegmentIndexFunction, * findSegmentPosition: shakaExtern.FindSegmentPositionFunction, * getSegmentReference: shakaExtern.GetSegmentReferenceFunction * }} * * @description * Contains functions used to create and find segment references. Used as * a return value, to temporarily store them before StreamInfo is created. * * @property {shakaExtern.CreateSegmentIndexFunction} createSegmentIndex * The createSegmentIndex function. * @property {shakaExtern.FindSegmentPositionFunction} findSegmentPosition * The findSegmentPosition function. * @property {shakaExtern.GetSegmentReferenceFunction} getSegmentReference * The getSegmentReference function. */ shaka.dash.DashParser.SegmentIndexFunctions; /** * @typedef {{ * createSegmentIndex: shakaExtern.CreateSegmentIndexFunction, * findSegmentPosition: shakaExtern.FindSegmentPositionFunction, * getSegmentReference: shakaExtern.GetSegmentReferenceFunction, * initSegmentReference: shaka.media.InitSegmentReference, * scaledPresentationTimeOffset: number * }} * * @description * Contains information about a Stream. This is passed from the createStream * methods. * * @property {shakaExtern.CreateSegmentIndexFunction} createSegmentIndex * The createSegmentIndex function for the stream. * @property {shakaExtern.FindSegmentPositionFunction} findSegmentPosition * The findSegmentPosition function for the stream. * @property {shakaExtern.GetSegmentReferenceFunction} getSegmentReference * The getSegmentReference function for the stream. * @property {shaka.media.InitSegmentReference} initSegmentReference * The init segment for the stream. * @property {number} scaledPresentationTimeOffset * The presentation time offset for the stream, in seconds. */ shaka.dash.DashParser.StreamInfo; /** * @override * @exportInterface */ shaka.dash.DashParser.prototype.configure = function(config) { goog.asserts.assert(config.dash != null, 'DashManifestConfiguration should not be null!'); this.config_ = config; }; /** * @override * @exportInterface */ shaka.dash.DashParser.prototype.start = function(uri, playerInterface) { goog.asserts.assert(this.config_, 'Must call configure() before start()!'); this.manifestUris_ = [uri]; this.playerInterface_ = playerInterface; return this.requestManifest_().then(function(updateDuration) { if (this.playerInterface_) { this.setUpdateTimer_(updateDuration); } return this.manifest_; }.bind(this)); }; /** * @override * @exportInterface */ shaka.dash.DashParser.prototype.stop = function() { this.playerInterface_ = null; this.config_ = null; this.manifestUris_ = []; this.manifest_ = null; this.periodIds_ = []; this.segmentIndexMap_ = {}; if (this.updateTimer_ != null) { window.clearTimeout(this.updateTimer_); this.updateTimer_ = null; } return this.operationManager_.destroy(); }; /** * @override * @exportInterface */ shaka.dash.DashParser.prototype.update = function() { this.requestManifest_().catch(function(error) { if (!this.playerInterface_) return; this.playerInterface_.onError(error); }.bind(this)); }; /** * @override * @exportInterface */ shaka.dash.DashParser.prototype.onExpirationUpdated = function( sessionId, expiration) { // No-op }; /** * Makes a network request for the manifest and parses the resulting data. * * @return {!Promise.} Resolves with the time it took, in seconds, to * fulfill the request and parse the data. * @private */ shaka.dash.DashParser.prototype.requestManifest_ = function() { const requestType = shaka.net.NetworkingEngine.RequestType.MANIFEST; let request = shaka.net.NetworkingEngine.makeRequest( this.manifestUris_, this.config_.retryParameters); let networkingEngine = this.playerInterface_.networkingEngine; const startTime = Date.now(); let operation = networkingEngine.request(requestType, request); this.operationManager_.manage(operation); return operation.promise.then((response) => { // Detect calls to stop(). if (!this.playerInterface_) { return; } // This may throw, but it will result in a failed promise. return this.parseManifest_(response.data, response.uri); }).then(() => { // Keep track of how long the longest manifest update took. const endTime = Date.now(); const updateDuration = (endTime - startTime) / 1000.0; this.maxUpdateDuration_ = Math.max(this.maxUpdateDuration_, updateDuration); // Let the caller know how long this update took. return updateDuration; }); }; /** * Parses the manifest XML. This also handles updates and will update the * stored manifest. * * @param {ArrayBuffer} data * @param {string} finalManifestUri The final manifest URI, which may * differ from this.manifestUri_ if there has been a redirect. * @return {!Promise} * @throws shaka.util.Error When there is a parsing error. * @private */ shaka.dash.DashParser.prototype.parseManifest_ = function(data, finalManifestUri) { const Error = shaka.util.Error; const MpdUtils = shaka.dash.MpdUtils; let mpd = MpdUtils.parseXml(data, 'MPD'); if (!mpd) { throw new Error( Error.Severity.CRITICAL, Error.Category.MANIFEST, Error.Code.DASH_INVALID_XML, finalManifestUri); } // Process the mpd to account for xlink connections. let failGracefully = this.config_.dash.xlinkFailGracefully; let xlinkOperation = MpdUtils.processXlinks( mpd, this.config_.retryParameters, failGracefully, finalManifestUri, this.playerInterface_.networkingEngine); this.operationManager_.manage(xlinkOperation); return xlinkOperation.promise.then((finalMpd) => { return this.processManifest_(finalMpd, finalManifestUri); }); }; /** * Takes a formatted MPD and converts it into a manifest. * * @param {!Element} mpd * @param {string} finalManifestUri The final manifest URI, which may * differ from this.manifestUri_ if there has been a redirect. * @return {!Promise} * @throws shaka.util.Error When there is a parsing error. * @private */ shaka.dash.DashParser.prototype.processManifest_ = function(mpd, finalManifestUri) { const Functional = shaka.util.Functional; const XmlUtils = shaka.util.XmlUtils; const ManifestParserUtils = shaka.util.ManifestParserUtils; // Get any Location elements. This will update the manifest location and // the base URI. /** @type {!Array.} */ let manifestBaseUris = [finalManifestUri]; /** @type {!Array.} */ let locations = XmlUtils.findChildren(mpd, 'Location') .map(XmlUtils.getContents) .filter(Functional.isNotNull); if (locations.length > 0) { this.manifestUris_ = locations; manifestBaseUris = locations; } let uris = XmlUtils.findChildren(mpd, 'BaseURL').map(XmlUtils.getContents); let baseUris = ManifestParserUtils.resolveUris(manifestBaseUris, uris); let minBufferTime = XmlUtils.parseAttr(mpd, 'minBufferTime', XmlUtils.parseDuration); this.updatePeriod_ = /** @type {number} */ (XmlUtils.parseAttr( mpd, 'minimumUpdatePeriod', XmlUtils.parseDuration, -1)); let presentationStartTime = XmlUtils.parseAttr( mpd, 'availabilityStartTime', XmlUtils.parseDate); let segmentAvailabilityDuration = XmlUtils.parseAttr( mpd, 'timeShiftBufferDepth', XmlUtils.parseDuration); let suggestedPresentationDelay = XmlUtils.parseAttr( mpd, 'suggestedPresentationDelay', XmlUtils.parseDuration); let maxSegmentDuration = XmlUtils.parseAttr( mpd, 'maxSegmentDuration', XmlUtils.parseDuration); let mpdType = mpd.getAttribute('type') || 'static'; /** @type {!shaka.media.PresentationTimeline} */ let presentationTimeline; if (this.manifest_) { presentationTimeline = this.manifest_.presentationTimeline; } else { // DASH IOP v3.0 suggests using a default delay between minBufferTime and // timeShiftBufferDepth. This is literally the range of all feasible // choices for the value. Nothing older than timeShiftBufferDepth is still // available, and anything less than minBufferTime will cause buffering // issues. // // We have decided that our default will be 1.5 * minBufferTime, // or 10s (configurable) whichever is larger. This is fairly conservative. // Content providers should provide a suggestedPresentationDelay // whenever possible to optimize the live streaming experience. let defaultPresentationDelay = Math.max( this.config_.dash.defaultPresentationDelay, minBufferTime * 1.5); let presentationDelay = suggestedPresentationDelay != null ? suggestedPresentationDelay : defaultPresentationDelay; presentationTimeline = new shaka.media.PresentationTimeline( presentationStartTime, presentationDelay); } /** @type {shaka.dash.DashParser.Context} */ let context = { // Don't base on updatePeriod_ since emsg boxes can cause manifest updates. dynamic: mpdType != 'static', presentationTimeline: presentationTimeline, period: null, periodInfo: null, adaptationSet: null, representation: null, bandwidth: 0, indexRangeWarningGiven: false }; let periodsAndDuration = this.parsePeriods_(context, baseUris, mpd); let duration = periodsAndDuration.duration; let periods = periodsAndDuration.periods; presentationTimeline.setStatic(mpdType == 'static'); if (mpdType == 'static' || !periodsAndDuration.durationDerivedFromPeriods) { // Ignore duration calculated from Period lengths if this is dynamic. presentationTimeline.setDuration(duration || Infinity); } presentationTimeline.setSegmentAvailabilityDuration( segmentAvailabilityDuration != null ? segmentAvailabilityDuration : Infinity); // Use @maxSegmentDuration to override smaller, derived values. presentationTimeline.notifyMaxSegmentDuration(maxSegmentDuration || 1); if (goog.DEBUG) presentationTimeline.assertIsValid(); if (this.manifest_) { // This is a manifest update, so we're done. return Promise.resolve(); } // This is the first manifest parse, so we cannot return until we calculate // the clock offset. let timingElements = XmlUtils.findChildren(mpd, 'UTCTiming'); let isLive = presentationTimeline.isLive(); return this.parseUtcTiming_( baseUris, timingElements, isLive).then(function(offset) { // Detect calls to stop(). if (!this.playerInterface_) { return; } presentationTimeline.setClockOffset(offset); this.manifest_ = { presentationTimeline: presentationTimeline, periods: periods, offlineSessionIds: [], minBufferTime: minBufferTime || 0 }; }.bind(this)); }; /** * Reads and parses the periods from the manifest. This first does some * partial parsing so the start and duration is available when parsing children. * * @param {shaka.dash.DashParser.Context} context * @param {!Array.} baseUris * @param {!Element} mpd * @return {{ * periods: !Array., * duration: ?number, * durationDerivedFromPeriods: boolean * }} * @private */ shaka.dash.DashParser.prototype.parsePeriods_ = function( context, baseUris, mpd) { const XmlUtils = shaka.util.XmlUtils; let presentationDuration = XmlUtils.parseAttr( mpd, 'mediaPresentationDuration', XmlUtils.parseDuration); let periods = []; let prevEnd = 0; let periodNodes = XmlUtils.findChildren(mpd, 'Period'); for (let i = 0; i < periodNodes.length; i++) { let elem = periodNodes[i]; let start = /** @type {number} */ ( XmlUtils.parseAttr(elem, 'start', XmlUtils.parseDuration, prevEnd)); let givenDuration = XmlUtils.parseAttr(elem, 'duration', XmlUtils.parseDuration); let periodDuration = null; if (i != periodNodes.length - 1) { // "The difference between the start time of a Period and the start time // of the following Period is the duration of the media content // represented by this Period." let nextPeriod = periodNodes[i + 1]; let nextStart = XmlUtils.parseAttr(nextPeriod, 'start', XmlUtils.parseDuration); if (nextStart != null) { periodDuration = nextStart - start; } } else if (presentationDuration != null) { // "The Period extends until the Period.start of the next Period, or // until the end of the Media Presentation in the case of the last // Period." periodDuration = presentationDuration - start; } let threshold = shaka.util.ManifestParserUtils.GAP_OVERLAP_TOLERANCE_SECONDS; if (periodDuration && givenDuration && Math.abs(periodDuration - givenDuration) > threshold) { shaka.log.warning('There is a gap/overlap between Periods', elem); } // Only use the @duration in the MPD if we can't calculate it. We should // favor the @start of the following Period. This ensures that there aren't // gaps between Periods. if (periodDuration == null) { periodDuration = givenDuration; } // Parse child nodes. let info = { start: start, duration: periodDuration, node: elem, index: i, isLastPeriod: periodDuration == null || i == periodNodes.length - 1 }; let period = this.parsePeriod_(context, baseUris, info); periods.push(period); // If the period ID is new, add it to the list. This must be done for both // the initial manifest parse and for updates. // See https://github.com/google/shaka-player/issues/963 let periodId = context.period.id; if (this.periodIds_.indexOf(periodId) == -1) { this.periodIds_.push(periodId); // If this is an update, call filterNewPeriod and add it to the manifest. // If this is the first parse of the manifest (this.manifest_ == null), // filterAllPeriods will be called later. if (this.manifest_) { this.playerInterface_.filterNewPeriod(period); this.manifest_.periods.push(period); } } if (periodDuration == null) { if (i != periodNodes.length - 1) { // If the duration is still null and we aren't at the end, then we will // skip any remaining periods. shaka.log.warning( 'Skipping Period', i + 1, 'and any subsequent Periods:', 'Period', i + 1, 'does not have a valid start time.', periods[i + 1]); } // The duration is unknown, so the end is unknown. prevEnd = null; break; } prevEnd = start + periodDuration; } // end of period parsing loop // Call filterAllPeriods if this is the initial parse. if (this.manifest_ == null) { this.playerInterface_.filterAllPeriods(periods); } if (presentationDuration != null) { if (prevEnd != presentationDuration) { shaka.log.warning( '@mediaPresentationDuration does not match the total duration of all', 'Periods.'); // Assume @mediaPresentationDuration is correct. } return { periods: periods, duration: presentationDuration, durationDerivedFromPeriods: false }; } else { return { periods: periods, duration: prevEnd, durationDerivedFromPeriods: true }; } }; /** * Parses a Period XML element. Unlike the other parse methods, this is not * given the Node; it is given a PeriodInfo structure. Also, partial parsing * was done before this was called so start and duration are valid. * * @param {shaka.dash.DashParser.Context} context * @param {!Array.} baseUris * @param {shaka.dash.DashParser.PeriodInfo} periodInfo * @return {shakaExtern.Period} * @throws shaka.util.Error When there is a parsing error. * @private */ shaka.dash.DashParser.prototype.parsePeriod_ = function( context, baseUris, periodInfo) { const Functional = shaka.util.Functional; const XmlUtils = shaka.util.XmlUtils; const ContentType = shaka.util.ManifestParserUtils.ContentType; context.period = this.createFrame_(periodInfo.node, null, baseUris); context.periodInfo = periodInfo; // If the period doesn't have an ID, give it one based on its start time. if (!context.period.id) { shaka.log.info( 'No Period ID given for Period with start time ' + periodInfo.start + ', Assigning a default'); context.period.id = '__shaka_period_' + periodInfo.start; } let eventStreamNodes = XmlUtils.findChildren(periodInfo.node, 'EventStream'); eventStreamNodes.forEach( this.parseEventStream_.bind(this, periodInfo.start, periodInfo.duration)); let adaptationSetNodes = XmlUtils.findChildren(periodInfo.node, 'AdaptationSet'); let adaptationSets = adaptationSetNodes .map(this.parseAdaptationSet_.bind(this, context)) .filter(Functional.isNotNull); let representationIds = adaptationSets .map(function(as) { return as.representationIds; }) .reduce(Functional.collapseArrays, []); let uniqueRepIds = representationIds.filter(Functional.isNotDuplicate); if (context.dynamic && representationIds.length != uniqueRepIds.length) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_DUPLICATE_REPRESENTATION_ID); } let normalAdaptationSets = adaptationSets .filter(function(as) { return !as.trickModeFor; }); let trickModeAdaptationSets = adaptationSets .filter(function(as) { return as.trickModeFor; }); // Attach trick mode tracks to normal tracks. trickModeAdaptationSets.forEach(function(trickModeSet) { // There may be multiple trick mode streams, but we do not currently // support that. Just choose one. let trickModeVideo = trickModeSet.streams[0]; let targetId = trickModeSet.trickModeFor; normalAdaptationSets.forEach(function(normalSet) { if (normalSet.id == targetId) { normalSet.streams.forEach(function(stream) { stream.trickModeVideo = trickModeVideo; }); } }); }); let videoSets = this.getSetsOfType_(normalAdaptationSets, ContentType.VIDEO); let audioSets = this.getSetsOfType_(normalAdaptationSets, ContentType.AUDIO); if (!videoSets.length && !audioSets.length) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_EMPTY_PERIOD); } // In case of audio-only or video-only content, we create an array of one item // containing a null. This way, the double-loop works for all kinds of // content. if (!audioSets.length) { audioSets = [null]; } if (!videoSets.length) { videoSets = [null]; } // TODO: Limit number of combinations. Come up with a heuristic // to decide which audio tracks to combine with which video tracks. let variants = []; for (let i = 0; i < audioSets.length; i++) { for (let j = 0; j < videoSets.length; j++) { let audioSet = audioSets[i]; let videoSet = videoSets[j]; this.createVariants_(audioSet, videoSet, variants); } } let textSets = this.getSetsOfType_(normalAdaptationSets, ContentType.TEXT); let textStreams = []; for (let i = 0; i < textSets.length; i++) { textStreams.push.apply(textStreams, textSets[i].streams); } return { startTime: periodInfo.start, textStreams: textStreams, variants: variants }; }; /** * @param {!Array.} adaptationSets * @param {string} type * @return {!Array.} * @private */ shaka.dash.DashParser.prototype.getSetsOfType_ = function( adaptationSets, type) { return adaptationSets.filter(function(as) { return as.contentType == type; }); }; /** * Combines Streams into Variants * * @param {?shaka.dash.DashParser.AdaptationInfo} audio * @param {?shaka.dash.DashParser.AdaptationInfo} video * @param {!Array.} variants New variants are pushed onto * this array. * @private */ shaka.dash.DashParser.prototype.createVariants_ = function(audio, video, variants) { const ContentType = shaka.util.ManifestParserUtils.ContentType; // Since both audio and video are of the same type, this assertion will catch // certain mistakes at runtime that the compiler would miss. goog.asserts.assert(!audio || audio.contentType == ContentType.AUDIO, 'Audio parameter mismatch!'); goog.asserts.assert(!video || video.contentType == ContentType.VIDEO, 'Video parameter mismatch!'); /** @type {number} */ let bandwidth; /** @type {shakaExtern.Variant} */ let variant; if (!audio && !video) { return; } if (audio && video) { // Audio+video variants const DrmEngine = shaka.media.DrmEngine; if (DrmEngine.areDrmCompatible(audio.drmInfos, video.drmInfos)) { let drmInfos = DrmEngine.getCommonDrmInfos(audio.drmInfos, video.drmInfos); for (let i = 0; i < audio.streams.length; i++) { for (let j = 0; j < video.streams.length; j++) { bandwidth = (video.streams[j].bandwidth || 0) + (audio.streams[i].bandwidth || 0); variant = { id: this.globalId_++, language: audio.language, primary: audio.main || video.main, audio: audio.streams[i], video: video.streams[j], bandwidth: bandwidth, drmInfos: drmInfos, allowedByApplication: true, allowedByKeySystem: true }; variants.push(variant); } } } } else { // Audio or video only variants let set = audio || video; for (let i = 0; i < set.streams.length; i++) { bandwidth = set.streams[i].bandwidth || 0; variant = { id: this.globalId_++, language: set.language || 'und', primary: set.main, audio: audio ? set.streams[i] : null, video: video ? set.streams[i] : null, bandwidth: bandwidth, drmInfos: set.drmInfos, allowedByApplication: true, allowedByKeySystem: true }; variants.push(variant); } } }; /** * Parses an AdaptationSet XML element. * * @param {shaka.dash.DashParser.Context} context * @param {!Element} elem The AdaptationSet element. * @return {?shaka.dash.DashParser.AdaptationInfo} * @throws shaka.util.Error When there is a parsing error. * @private */ shaka.dash.DashParser.prototype.parseAdaptationSet_ = function(context, elem) { const XmlUtils = shaka.util.XmlUtils; const Functional = shaka.util.Functional; const ManifestParserUtils = shaka.util.ManifestParserUtils; const ContentType = ManifestParserUtils.ContentType; context.adaptationSet = this.createFrame_(elem, context.period, null); let main = false; let roleElements = XmlUtils.findChildren(elem, 'Role'); let roleValues = roleElements.map(function(role) { return role.getAttribute('value'); }).filter(Functional.isNotNull); // Default kind for text streams is 'subtitle' if unspecified in the manifest. let kind = undefined; if (context.adaptationSet.contentType == ManifestParserUtils.ContentType.TEXT) { kind = ManifestParserUtils.TextStreamKind.SUBTITLE; } for (let i = 0; i < roleElements.length; i++) { let scheme = roleElements[i].getAttribute('schemeIdUri'); if (scheme == null || scheme == 'urn:mpeg:dash:role:2011') { // These only apply for the given scheme, but allow them to be specified // if there is no scheme specified. // See: DASH section 5.8.5.5 let value = roleElements[i].getAttribute('value'); switch (value) { case 'main': main = true; break; case 'caption': case 'subtitle': kind = value; break; } } } let essentialProperties = XmlUtils.findChildren(elem, 'EssentialProperty'); // ID of real AdaptationSet if this is a trick mode set: let trickModeFor = null; let unrecognizedEssentialProperty = false; essentialProperties.forEach(function(prop) { let schemeId = prop.getAttribute('schemeIdUri'); if (schemeId == 'http://dashif.org/guidelines/trickmode') { trickModeFor = prop.getAttribute('value'); } else { unrecognizedEssentialProperty = true; } }); // According to DASH spec (2014) section 5.8.4.8, "the successful processing // of the descriptor is essential to properly use the information in the // parent element". According to DASH IOP v3.3, section 3.3.4, "if the scheme // or the value" for EssentialProperty is not recognized, "the DASH client // shall ignore the parent element." if (unrecognizedEssentialProperty) { // Stop parsing this AdaptationSet and let the caller filter out the nulls. return null; } let contentProtectionElems = XmlUtils.findChildren(elem, 'ContentProtection'); let contentProtection = shaka.dash.ContentProtection.parseFromAdaptationSet( contentProtectionElems, this.config_.dash.customScheme, this.config_.dash.ignoreDrmInfo); let language = shaka.util.LanguageUtils.normalize(elem.getAttribute('lang') || 'und'); // This attribute is currently non-standard, but it is supported by Kaltura. let label = elem.getAttribute('label'); // Parse Representations into Streams. let representations = XmlUtils.findChildren(elem, 'Representation'); let streams = representations .map(this.parseRepresentation_.bind(this, context, contentProtection, kind, language, label, main, roleValues)) .filter(function(s) { return !!s; }); if (streams.length == 0) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_EMPTY_ADAPTATION_SET); } // If AdaptationSet's type is unknown or is ambiguously "application", // guess based on the information in the first stream. If the attributes // mimeType and codecs are split across levels, they will both be inherited // down to the stream level by this point, so the stream will have all the // necessary information. if (!context.adaptationSet.contentType || context.adaptationSet.contentType == ContentType.APPLICATION) { let mimeType = streams[0].mimeType; let codecs = streams[0].codecs; context.adaptationSet.contentType = shaka.dash.DashParser.guessContentType_(mimeType, codecs); streams.forEach(function(stream) { stream.type = context.adaptationSet.contentType; }); } streams.forEach(function(stream) { // Some DRM license providers require that we have a default // key ID from the manifest in the wrapped license request. // Thus, it should be put in drmInfo to be accessible to request filters. contentProtection.drmInfos.forEach(function(drmInfo) { if (stream.keyId) { drmInfo.keyIds.push(stream.keyId); } }); }); let repIds = representations .map(function(node) { return node.getAttribute('id'); }) .filter(shaka.util.Functional.isNotNull); return { id: context.adaptationSet.id || ('__fake__' + this.globalId_++), contentType: context.adaptationSet.contentType, language: language, main: main, streams: streams, drmInfos: contentProtection.drmInfos, trickModeFor: trickModeFor, representationIds: repIds }; }; /** * Parses a Representation XML element. * * @param {shaka.dash.DashParser.Context} context * @param {shaka.dash.ContentProtection.Context} contentProtection * @param {(string|undefined)} kind * @param {string} language * @param {string} label * @param {boolean} isPrimary * @param {!Array.} roles * @param {!Element} node * @return {?shakaExtern.Stream} The Stream, or null when there is a * non-critical parsing error. * @throws shaka.util.Error When there is a parsing error. * @private */ shaka.dash.DashParser.prototype.parseRepresentation_ = function( context, contentProtection, kind, language, label, isPrimary, roles, node) { const XmlUtils = shaka.util.XmlUtils; const ContentType = shaka.util.ManifestParserUtils.ContentType; context.representation = this.createFrame_(node, context.adaptationSet, null); if (!this.verifyRepresentation_(context.representation)) { shaka.log.warning('Skipping Representation', context.representation); return null; } // NOTE: bandwidth is a mandatory attribute according to the spec, and zero // does not make sense in the DASH spec's bandwidth formulas. // In some content, however, the attribute is missing or zero. // To avoid NaN at the variant level on broken content, fall back to zero. // https://github.com/google/shaka-player/issues/938#issuecomment-317278180 context.bandwidth = XmlUtils.parseAttr(node, 'bandwidth', XmlUtils.parsePositiveInt) || 0; /** @type {?shaka.dash.DashParser.StreamInfo} */ let streamInfo; let requestInitSegment = this.requestInitSegment_.bind(this); if (context.representation.segmentBase) { streamInfo = shaka.dash.SegmentBase.createStream( context, requestInitSegment); } else if (context.representation.segmentList) { streamInfo = shaka.dash.SegmentList.createStream( context, this.segmentIndexMap_); } else if (context.representation.segmentTemplate) { streamInfo = shaka.dash.SegmentTemplate.createStream( context, requestInitSegment, this.segmentIndexMap_, !!this.manifest_); } else { goog.asserts.assert( context.representation.contentType == ContentType.TEXT || context.representation.contentType == ContentType.APPLICATION, 'Must have Segment* with non-text streams.'); let baseUris = context.representation.baseUris; let duration = context.periodInfo.duration || 0; streamInfo = { createSegmentIndex: Promise.resolve.bind(Promise), findSegmentPosition: /** @return {?number} */ function(/** number */ time) { if (time >= 0 && time < duration) { return 1; } else { return null; } }, getSegmentReference: /** @return {shaka.media.SegmentReference} */ function(/** number */ ref) { if (ref != 1) { return null; } return new shaka.media.SegmentReference( 1, 0, duration, function() { return baseUris; }, 0, null); }, initSegmentReference: null, scaledPresentationTimeOffset: 0 }; } let contentProtectionElems = XmlUtils.findChildren(node, 'ContentProtection'); let keyId = shaka.dash.ContentProtection.parseFromRepresentation( contentProtectionElems, this.config_.dash.customScheme, contentProtection, this.config_.dash.ignoreDrmInfo); return { id: this.globalId_++, createSegmentIndex: streamInfo.createSegmentIndex, findSegmentPosition: streamInfo.findSegmentPosition, getSegmentReference: streamInfo.getSegmentReference, initSegmentReference: streamInfo.initSegmentReference, presentationTimeOffset: streamInfo.scaledPresentationTimeOffset, mimeType: context.representation.mimeType, codecs: context.representation.codecs, frameRate: context.representation.frameRate, bandwidth: context.bandwidth, width: context.representation.width, height: context.representation.height, kind: kind, encrypted: contentProtection.drmInfos.length > 0, keyId: keyId, language: language, label: label, type: context.adaptationSet.contentType, primary: isPrimary, trickModeVideo: null, containsEmsgBoxes: context.representation.containsEmsgBoxes, roles: roles, channelCount: context.representation.numChannels }; }; /** * Called when the update timer ticks. * * @private */ shaka.dash.DashParser.prototype.onUpdate_ = function() { goog.asserts.assert(this.updateTimer_, 'Should only be called by timer'); goog.asserts.assert(this.updatePeriod_ >= 0, 'There should be an update period'); shaka.log.info('Updating manifest...'); this.updateTimer_ = null; this.requestManifest_().then(function(updateDuration) { // Detect a call to stop() if (!this.playerInterface_) { return; } // Ensure the next update occurs within |updatePeriod_| seconds by taking // into account the time it took to update the manifest. this.setUpdateTimer_(updateDuration); }.bind(this)).catch(function(error) { goog.asserts.assert(error instanceof shaka.util.Error, 'Should only receive a Shaka error'); // Try updating again, but ensure we haven't been destroyed. if (this.playerInterface_) { // We will retry updating, so override the severity of the error. error.severity = shaka.util.Error.Severity.RECOVERABLE; this.playerInterface_.onError(error); this.setUpdateTimer_(0); } }.bind(this)); }; /** * Sets the update timer. Does nothing if the manifest does not specify an * update period. * * @param {number} offset An offset, in seconds, to apply to the manifest's * update period. * @private */ shaka.dash.DashParser.prototype.setUpdateTimer_ = function(offset) { // NOTE: An updatePeriod_ of -1 means the attribute was missing. // An attribute which is present and set to 0 should still result in periodic // updates. For more, see: https://github.com/google/shaka-player/issues/331 if (this.updatePeriod_ < 0) { return; } goog.asserts.assert(this.updateTimer_ == null, 'Timer should not be already set'); let period = Math.max( shaka.dash.DashParser.MIN_UPDATE_PERIOD_, this.updatePeriod_ - offset, this.maxUpdateDuration_ + 1); shaka.log.debug('actual update period', period); let callback = this.onUpdate_.bind(this); this.updateTimer_ = window.setTimeout(callback, 1000 * period); }; /** * Creates a new inheritance frame for the given element. * * @param {!Element} elem * @param {?shaka.dash.DashParser.InheritanceFrame} parent * @param {Array.} baseUris * @return {shaka.dash.DashParser.InheritanceFrame} * @private */ shaka.dash.DashParser.prototype.createFrame_ = function( elem, parent, baseUris) { goog.asserts.assert(parent || baseUris, 'Must provide either parent or baseUris'); const ManifestParserUtils = shaka.util.ManifestParserUtils; const XmlUtils = shaka.util.XmlUtils; parent = parent || /** @type {shaka.dash.DashParser.InheritanceFrame} */ ({ contentType: '', mimeType: '', codecs: '', containsEmsgBoxes: false, frameRate: undefined, numChannels: null }); baseUris = baseUris || parent.baseUris; let parseNumber = XmlUtils.parseNonNegativeInt; let evalDivision = XmlUtils.evalDivision; let uris = XmlUtils.findChildren(elem, 'BaseURL').map(XmlUtils.getContents); let contentType = elem.getAttribute('contentType') || parent.contentType; let mimeType = elem.getAttribute('mimeType') || parent.mimeType; let codecs = elem.getAttribute('codecs') || parent.codecs; let frameRate = XmlUtils.parseAttr(elem, 'frameRate', evalDivision) || parent.frameRate; let containsEmsgBoxes = !!XmlUtils.findChildren(elem, 'InbandEventStream').length; let audioChannelConfigs = XmlUtils.findChildren(elem, 'AudioChannelConfiguration'); let numChannels = this.parseAudioChannels_(audioChannelConfigs) || parent.numChannels; if (!contentType) { contentType = shaka.dash.DashParser.guessContentType_(mimeType, codecs); } return { baseUris: ManifestParserUtils.resolveUris(baseUris, uris), segmentBase: XmlUtils.findChild(elem, 'SegmentBase') || parent.segmentBase, segmentList: XmlUtils.findChild(elem, 'SegmentList') || parent.segmentList, segmentTemplate: XmlUtils.findChild(elem, 'SegmentTemplate') || parent.segmentTemplate, width: XmlUtils.parseAttr(elem, 'width', parseNumber) || parent.width, height: XmlUtils.parseAttr(elem, 'height', parseNumber) || parent.height, contentType: contentType, mimeType: mimeType, codecs: codecs, frameRate: frameRate, containsEmsgBoxes: containsEmsgBoxes || parent.containsEmsgBoxes, id: elem.getAttribute('id'), numChannels: numChannels }; }; /** * @param {!Array.} audioChannelConfigs An array of * AudioChannelConfiguration elements. * @return {?number} The number of audio channels, or null if unknown. * @private */ shaka.dash.DashParser.prototype.parseAudioChannels_ = function(audioChannelConfigs) { for (let i = 0; i < audioChannelConfigs.length; ++i) { let elem = audioChannelConfigs[i]; let scheme = elem.getAttribute('schemeIdUri'); if (!scheme) continue; let value = elem.getAttribute('value'); if (!value) continue; switch (scheme) { case 'urn:mpeg:dash:outputChannelPositionList:2012': // A space-separated list of speaker positions, so the number of // channels is the length of this list. return value.trim().split(/ +/).length; case 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011': case 'urn:dts:dash:audio_channel_configuration:2012': { // As far as we can tell, this is a number of channels. let intValue = parseInt(value, 10); if (!intValue) { // 0 or NaN shaka.log.warning('Channel parsing failure! ' + 'Ignoring scheme and value', scheme, value); continue; } return intValue; } case 'tag:dolby.com,2014:dash:audio_channel_configuration:2011': case 'urn:dolby:dash:audio_channel_configuration:2011': { // A hex-encoded 16-bit integer, in which each bit represents a channel. let hexValue = parseInt(value, 16); if (!hexValue) { // 0 or NaN shaka.log.warning('Channel parsing failure! ' + 'Ignoring scheme and value', scheme, value); continue; } // Count the 1-bits in hexValue. let numBits = 0; while (hexValue) { if (hexValue & 1) ++numBits; hexValue >>= 1; } return numBits; } default: shaka.log.warning('Unrecognized audio channel scheme:', scheme, value); continue; } } return null; }; /** * Verifies that a Representation has exactly one Segment* element. Prints * warnings if there is a problem. * * @param {shaka.dash.DashParser.InheritanceFrame} frame * @return {boolean} True if the Representation is usable; otherwise return * false. * @private */ shaka.dash.DashParser.prototype.verifyRepresentation_ = function(frame) { const ContentType = shaka.util.ManifestParserUtils.ContentType; let n = 0; n += frame.segmentBase ? 1 : 0; n += frame.segmentList ? 1 : 0; n += frame.segmentTemplate ? 1 : 0; if (n == 0) { // TODO: Extend with the list of MIME types registered to TextEngine. if (frame.contentType == ContentType.TEXT || frame.contentType == ContentType.APPLICATION) { return true; } else { shaka.log.warning( 'Representation does not contain a segment information source:', 'the Representation must contain one of SegmentBase, SegmentList,', 'SegmentTemplate, or explicitly indicate that it is "text".', frame); return false; } } if (n != 1) { shaka.log.warning( 'Representation contains multiple segment information sources:', 'the Representation should only contain one of SegmentBase,', 'SegmentList, or SegmentTemplate.', frame); if (frame.segmentBase) { shaka.log.info('Using SegmentBase by default.'); frame.segmentList = null; frame.segmentTemplate = null; } else { goog.asserts.assert(frame.segmentList, 'There should be a SegmentList'); shaka.log.info('Using SegmentList by default.'); frame.segmentTemplate = null; } } return true; }; /** * Makes a request to the given URI and calculates the clock offset. * * @param {!Array.} baseUris * @param {string} uri * @param {string} method * @return {!Promise.} * @private */ shaka.dash.DashParser.prototype.requestForTiming_ = function(baseUris, uri, method) { let requestUris = shaka.util.ManifestParserUtils.resolveUris(baseUris, [uri]); let request = shaka.net.NetworkingEngine.makeRequest( requestUris, this.config_.retryParameters); request.method = method; const type = shaka.net.NetworkingEngine.RequestType.MANIFEST; let operation = this.playerInterface_.networkingEngine.request(type, request); this.operationManager_.manage(operation); return operation.promise.then((response) => { let text; if (method == 'HEAD') { if (!response.headers || !response.headers['date']) { shaka.log.warning('UTC timing response is missing', 'expected date header'); return 0; } text = response.headers['date']; } else { text = shaka.util.StringUtils.fromUTF8(response.data); } let date = Date.parse(text); if (isNaN(date)) { shaka.log.warning('Unable to parse date from UTC timing response'); return 0; } return (date - Date.now()); }); }; /** * Parses an array of UTCTiming elements. * * @param {!Array.} baseUris * @param {!Array.} elems * @param {boolean} isLive * @return {!Promise.} * @private */ shaka.dash.DashParser.prototype.parseUtcTiming_ = function(baseUris, elems, isLive) { let schemesAndValues = elems.map(function(elem) { return { scheme: elem.getAttribute('schemeIdUri'), value: elem.getAttribute('value') }; }); // If there's nothing specified in the manifest, but we have a default from // the config, use that. let clockSyncUri = this.config_.dash.clockSyncUri; if (isLive && !schemesAndValues.length && clockSyncUri) { schemesAndValues.push({ scheme: 'urn:mpeg:dash:utc:http-head:2014', value: clockSyncUri }); } const Functional = shaka.util.Functional; return Functional.createFallbackPromiseChain(schemesAndValues, function(sv) { let scheme = sv.scheme; let value = sv.value; switch (scheme) { // See DASH IOP Guidelines Section 4.7 // http://goo.gl/CQFNJT // Some old ISO23009-1 drafts used 2012. case 'urn:mpeg:dash:utc:http-head:2014': case 'urn:mpeg:dash:utc:http-head:2012': return this.requestForTiming_(baseUris, value, 'HEAD'); case 'urn:mpeg:dash:utc:http-xsdate:2014': case 'urn:mpeg:dash:utc:http-iso:2014': case 'urn:mpeg:dash:utc:http-xsdate:2012': case 'urn:mpeg:dash:utc:http-iso:2012': return this.requestForTiming_(baseUris, value, 'GET'); case 'urn:mpeg:dash:utc:direct:2014': case 'urn:mpeg:dash:utc:direct:2012': { let date = Date.parse(value); return isNaN(date) ? 0 : (date - Date.now()); } case 'urn:mpeg:dash:utc:http-ntp:2014': case 'urn:mpeg:dash:utc:ntp:2014': case 'urn:mpeg:dash:utc:sntp:2014': shaka.log.warning('NTP UTCTiming scheme is not supported'); return Promise.reject(); default: shaka.log.warning( 'Unrecognized scheme in UTCTiming element', scheme); return Promise.reject(); } }.bind(this)).catch(function() { if (isLive) { shaka.log.warning( 'A UTCTiming element should always be given in live manifests! ' + 'This content may not play on clients with bad clocks!'); } return 0; }); }; /** * Parses an EventStream element. * * @param {number} periodStart * @param {?number} periodDuration * @param {!Element} elem * @private */ shaka.dash.DashParser.prototype.parseEventStream_ = function( periodStart, periodDuration, elem) { const XmlUtils = shaka.util.XmlUtils; let parseNumber = XmlUtils.parseNonNegativeInt; let schemeIdUri = elem.getAttribute('schemeIdUri') || ''; let value = elem.getAttribute('value') || ''; let timescale = XmlUtils.parseAttr(elem, 'timescale', parseNumber) || 1; XmlUtils.findChildren(elem, 'Event').forEach(function(eventNode) { let presentationTime = XmlUtils.parseAttr(eventNode, 'presentationTime', parseNumber) || 0; let duration = XmlUtils.parseAttr(eventNode, 'duration', parseNumber) || 0; let startTime = presentationTime / timescale + periodStart; let endTime = startTime + (duration / timescale); if (periodDuration != null) { // An event should not go past the Period, even if the manifest says so. // See: Dash sec. 5.10.2.1 startTime = Math.min(startTime, periodStart + periodDuration); endTime = Math.min(endTime, periodStart + periodDuration); } /** @type {shakaExtern.TimelineRegionInfo} */ let region = { schemeIdUri: schemeIdUri, value: value, startTime: startTime, endTime: endTime, id: eventNode.getAttribute('id') || '', eventElement: eventNode }; this.playerInterface_.onTimelineRegionAdded(region); }.bind(this)); }; /** * Makes a network request on behalf of SegmentBase.createStream. * * @param {!Array.} uris * @param {?number} startByte * @param {?number} endByte * @return {!Promise.} * @private */ shaka.dash.DashParser.prototype.requestInitSegment_ = function( uris, startByte, endByte) { const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT; let request = shaka.net.NetworkingEngine.makeRequest( uris, this.config_.retryParameters); if (startByte != null) { let end = (endByte != null ? endByte : ''); request.headers['Range'] = 'bytes=' + startByte + '-' + end; } let networkingEngine = this.playerInterface_.networkingEngine; let operation = networkingEngine.request(requestType, request); this.operationManager_.manage(operation); return operation.promise.then((response) => response.data); }; /** * Guess the content type based on MIME type and codecs. * * @param {string} mimeType * @param {string} codecs * @return {string} * @private */ shaka.dash.DashParser.guessContentType_ = function(mimeType, codecs) { let fullMimeType = shaka.util.MimeUtils.getFullType(mimeType, codecs); if (shaka.text.TextEngine.isTypeSupported(fullMimeType)) { // If it's supported by TextEngine, it's definitely text. // We don't check MediaSourceEngine, because that would report support // for platform-supported video and audio types as well. return shaka.util.ManifestParserUtils.ContentType.TEXT; } // Otherwise, just split the MIME type. This handles video and audio // types well. return mimeType.split('/')[0]; }; shaka.media.ManifestParser.registerParserByExtension( 'mpd', shaka.dash.DashParser); shaka.media.ManifestParser.registerParserByMime( 'application/dash+xml', shaka.dash.DashParser);