/*! @name mpd-parser @version 1.1.1 @license Apache-2.0 */ import resolveUrl from '@videojs/vhs-utils/es/resolve-url'; import window from 'global/window'; import { forEachMediaGroup } from '@videojs/vhs-utils/es/media-groups'; import decodeB64ToUint8Array from '@videojs/vhs-utils/es/decode-b64-to-uint8-array'; import { DOMParser } from '@xmldom/xmldom'; var version = "1.1.1"; const isObject = obj => { return !!obj && typeof obj === 'object'; }; const merge = (...objects) => { return objects.reduce((result, source) => { if (typeof source !== 'object') { return result; } Object.keys(source).forEach(key => { if (Array.isArray(result[key]) && Array.isArray(source[key])) { result[key] = result[key].concat(source[key]); } else if (isObject(result[key]) && isObject(source[key])) { result[key] = merge(result[key], source[key]); } else { result[key] = source[key]; } }); return result; }, {}); }; const values = o => Object.keys(o).map(k => o[k]); const range = (start, end) => { const result = []; for (let i = start; i < end; i++) { result.push(i); } return result; }; const flatten = lists => lists.reduce((x, y) => x.concat(y), []); const from = list => { if (!list.length) { return []; } const result = []; for (let i = 0; i < list.length; i++) { result.push(list[i]); } return result; }; const findIndexes = (l, key) => l.reduce((a, e, i) => { if (e[key]) { a.push(i); } return a; }, []); /** * Returns a union of the included lists provided each element can be identified by a key. * * @param {Array} list - list of lists to get the union of * @param {Function} keyFunction - the function to use as a key for each element * * @return {Array} the union of the arrays */ const union = (lists, keyFunction) => { return values(lists.reduce((acc, list) => { list.forEach(el => { acc[keyFunction(el)] = el; }); return acc; }, {})); }; var errors = { INVALID_NUMBER_OF_PERIOD: 'INVALID_NUMBER_OF_PERIOD', DASH_EMPTY_MANIFEST: 'DASH_EMPTY_MANIFEST', DASH_INVALID_XML: 'DASH_INVALID_XML', NO_BASE_URL: 'NO_BASE_URL', MISSING_SEGMENT_INFORMATION: 'MISSING_SEGMENT_INFORMATION', SEGMENT_TIME_UNSPECIFIED: 'SEGMENT_TIME_UNSPECIFIED', UNSUPPORTED_UTC_TIMING_SCHEME: 'UNSUPPORTED_UTC_TIMING_SCHEME' }; /** * @typedef {Object} SingleUri * @property {string} uri - relative location of segment * @property {string} resolvedUri - resolved location of segment * @property {Object} byterange - Object containing information on how to make byte range * requests following byte-range-spec per RFC2616. * @property {String} byterange.length - length of range request * @property {String} byterange.offset - byte offset of range request * * @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35.1 */ /** * Converts a URLType node (5.3.9.2.3 Table 13) to a segment object * that conforms to how m3u8-parser is structured * * @see https://github.com/videojs/m3u8-parser * * @param {string} baseUrl - baseUrl provided by nodes * @param {string} source - source url for segment * @param {string} range - optional range used for range calls, * follows RFC 2616, Clause 14.35.1 * @return {SingleUri} full segment information transformed into a format similar * to m3u8-parser */ const urlTypeToSegment = ({ baseUrl = '', source = '', range = '', indexRange = '' }) => { const segment = { uri: source, resolvedUri: resolveUrl(baseUrl || '', source) }; if (range || indexRange) { const rangeStr = range ? range : indexRange; const ranges = rangeStr.split('-'); // default to parsing this as a BigInt if possible let startRange = window.BigInt ? window.BigInt(ranges[0]) : parseInt(ranges[0], 10); let endRange = window.BigInt ? window.BigInt(ranges[1]) : parseInt(ranges[1], 10); // convert back to a number if less than MAX_SAFE_INTEGER if (startRange < Number.MAX_SAFE_INTEGER && typeof startRange === 'bigint') { startRange = Number(startRange); } if (endRange < Number.MAX_SAFE_INTEGER && typeof endRange === 'bigint') { endRange = Number(endRange); } let length; if (typeof endRange === 'bigint' || typeof startRange === 'bigint') { length = window.BigInt(endRange) - window.BigInt(startRange) + window.BigInt(1); } else { length = endRange - startRange + 1; } if (typeof length === 'bigint' && length < Number.MAX_SAFE_INTEGER) { length = Number(length); } // byterange should be inclusive according to // RFC 2616, Clause 14.35.1 segment.byterange = { length, offset: startRange }; } return segment; }; const byteRangeToString = byterange => { // `endRange` is one less than `offset + length` because the HTTP range // header uses inclusive ranges let endRange; if (typeof byterange.offset === 'bigint' || typeof byterange.length === 'bigint') { endRange = window.BigInt(byterange.offset) + window.BigInt(byterange.length) - window.BigInt(1); } else { endRange = byterange.offset + byterange.length - 1; } return `${byterange.offset}-${endRange}`; }; /** * parse the end number attribue that can be a string * number, or undefined. * * @param {string|number|undefined} endNumber * The end number attribute. * * @return {number|null} * The result of parsing the end number. */ const parseEndNumber = endNumber => { if (endNumber && typeof endNumber !== 'number') { endNumber = parseInt(endNumber, 10); } if (isNaN(endNumber)) { return null; } return endNumber; }; /** * Functions for calculating the range of available segments in static and dynamic * manifests. */ const segmentRange = { /** * Returns the entire range of available segments for a static MPD * * @param {Object} attributes * Inheritied MPD attributes * @return {{ start: number, end: number }} * The start and end numbers for available segments */ static(attributes) { const { duration, timescale = 1, sourceDuration, periodDuration } = attributes; const endNumber = parseEndNumber(attributes.endNumber); const segmentDuration = duration / timescale; if (typeof endNumber === 'number') { return { start: 0, end: endNumber }; } if (typeof periodDuration === 'number') { return { start: 0, end: periodDuration / segmentDuration }; } return { start: 0, end: sourceDuration / segmentDuration }; }, /** * Returns the current live window range of available segments for a dynamic MPD * * @param {Object} attributes * Inheritied MPD attributes * @return {{ start: number, end: number }} * The start and end numbers for available segments */ dynamic(attributes) { const { NOW, clientOffset, availabilityStartTime, timescale = 1, duration, periodStart = 0, minimumUpdatePeriod = 0, timeShiftBufferDepth = Infinity } = attributes; const endNumber = parseEndNumber(attributes.endNumber); // clientOffset is passed in at the top level of mpd-parser and is an offset calculated // after retrieving UTC server time. const now = (NOW + clientOffset) / 1000; // WC stands for Wall Clock. // Convert the period start time to EPOCH. const periodStartWC = availabilityStartTime + periodStart; // Period end in EPOCH is manifest's retrieval time + time until next update. const periodEndWC = now + minimumUpdatePeriod; const periodDuration = periodEndWC - periodStartWC; const segmentCount = Math.ceil(periodDuration * timescale / duration); const availableStart = Math.floor((now - periodStartWC - timeShiftBufferDepth) * timescale / duration); const availableEnd = Math.floor((now - periodStartWC) * timescale / duration); return { start: Math.max(0, availableStart), end: typeof endNumber === 'number' ? endNumber : Math.min(segmentCount, availableEnd) }; } }; /** * Maps a range of numbers to objects with information needed to build the corresponding * segment list * * @name toSegmentsCallback * @function * @param {number} number * Number of the segment * @param {number} index * Index of the number in the range list * @return {{ number: Number, duration: Number, timeline: Number, time: Number }} * Object with segment timing and duration info */ /** * Returns a callback for Array.prototype.map for mapping a range of numbers to * information needed to build the segment list. * * @param {Object} attributes * Inherited MPD attributes * @return {toSegmentsCallback} * Callback map function */ const toSegments = attributes => number => { const { duration, timescale = 1, periodStart, startNumber = 1 } = attributes; return { number: startNumber + number, duration: duration / timescale, timeline: periodStart, time: number * duration }; }; /** * Returns a list of objects containing segment timing and duration info used for * building the list of segments. This uses the @duration attribute specified * in the MPD manifest to derive the range of segments. * * @param {Object} attributes * Inherited MPD attributes * @return {{number: number, duration: number, time: number, timeline: number}[]} * List of Objects with segment timing and duration info */ const parseByDuration = attributes => { const { type, duration, timescale = 1, periodDuration, sourceDuration } = attributes; const { start, end } = segmentRange[type](attributes); const segments = range(start, end).map(toSegments(attributes)); if (type === 'static') { const index = segments.length - 1; // section is either a period or the full source const sectionDuration = typeof periodDuration === 'number' ? periodDuration : sourceDuration; // final segment may be less than full segment duration segments[index].duration = sectionDuration - duration / timescale * index; } return segments; }; /** * Translates SegmentBase into a set of segments. * (DASH SPEC Section 5.3.9.3.2) contains a set of nodes. Each * node should be translated into segment. * * @param {Object} attributes * Object containing all inherited attributes from parent elements with attribute * names as keys * @return {Object.} list of segments */ const segmentsFromBase = attributes => { const { baseUrl, initialization = {}, sourceDuration, indexRange = '', periodStart, presentationTime, number = 0, duration } = attributes; // base url is required for SegmentBase to work, per spec (Section 5.3.9.2.1) if (!baseUrl) { throw new Error(errors.NO_BASE_URL); } const initSegment = urlTypeToSegment({ baseUrl, source: initialization.sourceURL, range: initialization.range }); const segment = urlTypeToSegment({ baseUrl, source: baseUrl, indexRange }); segment.map = initSegment; // If there is a duration, use it, otherwise use the given duration of the source // (since SegmentBase is only for one total segment) if (duration) { const segmentTimeInfo = parseByDuration(attributes); if (segmentTimeInfo.length) { segment.duration = segmentTimeInfo[0].duration; segment.timeline = segmentTimeInfo[0].timeline; } } else if (sourceDuration) { segment.duration = sourceDuration; segment.timeline = periodStart; } // If presentation time is provided, these segments are being generated by SIDX // references, and should use the time provided. For the general case of SegmentBase, // there should only be one segment in the period, so its presentation time is the same // as its period start. segment.presentationTime = presentationTime || periodStart; segment.number = number; return [segment]; }; /** * Given a playlist, a sidx box, and a baseUrl, update the segment list of the playlist * according to the sidx information given. * * playlist.sidx has metadadata about the sidx where-as the sidx param * is the parsed sidx box itself. * * @param {Object} playlist the playlist to update the sidx information for * @param {Object} sidx the parsed sidx box * @return {Object} the playlist object with the updated sidx information */ const addSidxSegmentsToPlaylist$1 = (playlist, sidx, baseUrl) => { // Retain init segment information const initSegment = playlist.sidx.map ? playlist.sidx.map : null; // Retain source duration from initial main manifest parsing const sourceDuration = playlist.sidx.duration; // Retain source timeline const timeline = playlist.timeline || 0; const sidxByteRange = playlist.sidx.byterange; const sidxEnd = sidxByteRange.offset + sidxByteRange.length; // Retain timescale of the parsed sidx const timescale = sidx.timescale; // referenceType 1 refers to other sidx boxes const mediaReferences = sidx.references.filter(r => r.referenceType !== 1); const segments = []; const type = playlist.endList ? 'static' : 'dynamic'; const periodStart = playlist.sidx.timeline; let presentationTime = periodStart; let number = playlist.mediaSequence || 0; // firstOffset is the offset from the end of the sidx box let startIndex; // eslint-disable-next-line if (typeof sidx.firstOffset === 'bigint') { startIndex = window.BigInt(sidxEnd) + sidx.firstOffset; } else { startIndex = sidxEnd + sidx.firstOffset; } for (let i = 0; i < mediaReferences.length; i++) { const reference = sidx.references[i]; // size of the referenced (sub)segment const size = reference.referencedSize; // duration of the referenced (sub)segment, in the timescale // this will be converted to seconds when generating segments const duration = reference.subsegmentDuration; // should be an inclusive range let endIndex; // eslint-disable-next-line if (typeof startIndex === 'bigint') { endIndex = startIndex + window.BigInt(size) - window.BigInt(1); } else { endIndex = startIndex + size - 1; } const indexRange = `${startIndex}-${endIndex}`; const attributes = { baseUrl, timescale, timeline, periodStart, presentationTime, number, duration, sourceDuration, indexRange, type }; const segment = segmentsFromBase(attributes)[0]; if (initSegment) { segment.map = initSegment; } segments.push(segment); if (typeof startIndex === 'bigint') { startIndex += window.BigInt(size); } else { startIndex += size; } presentationTime += duration / timescale; number++; } playlist.segments = segments; return playlist; }; const SUPPORTED_MEDIA_TYPES = ['AUDIO', 'SUBTITLES']; // allow one 60fps frame as leniency (arbitrarily chosen) const TIME_FUDGE = 1 / 60; /** * Given a list of timelineStarts, combines, dedupes, and sorts them. * * @param {TimelineStart[]} timelineStarts - list of timeline starts * * @return {TimelineStart[]} the combined and deduped timeline starts */ const getUniqueTimelineStarts = timelineStarts => { return union(timelineStarts, ({ timeline }) => timeline).sort((a, b) => a.timeline > b.timeline ? 1 : -1); }; /** * Finds the playlist with the matching NAME attribute. * * @param {Array} playlists - playlists to search through * @param {string} name - the NAME attribute to search for * * @return {Object|null} the matching playlist object, or null */ const findPlaylistWithName = (playlists, name) => { for (let i = 0; i < playlists.length; i++) { if (playlists[i].attributes.NAME === name) { return playlists[i]; } } return null; }; /** * Gets a flattened array of media group playlists. * * @param {Object} manifest - the main manifest object * * @return {Array} the media group playlists */ const getMediaGroupPlaylists = manifest => { let mediaGroupPlaylists = []; forEachMediaGroup(manifest, SUPPORTED_MEDIA_TYPES, (properties, type, group, label) => { mediaGroupPlaylists = mediaGroupPlaylists.concat(properties.playlists || []); }); return mediaGroupPlaylists; }; /** * Updates the playlist's media sequence numbers. * * @param {Object} config - options object * @param {Object} config.playlist - the playlist to update * @param {number} config.mediaSequence - the mediaSequence number to start with */ const updateMediaSequenceForPlaylist = ({ playlist, mediaSequence }) => { playlist.mediaSequence = mediaSequence; playlist.segments.forEach((segment, index) => { segment.number = playlist.mediaSequence + index; }); }; /** * Updates the media and discontinuity sequence numbers of newPlaylists given oldPlaylists * and a complete list of timeline starts. * * If no matching playlist is found, only the discontinuity sequence number of the playlist * will be updated. * * Since early available timelines are not supported, at least one segment must be present. * * @param {Object} config - options object * @param {Object[]} oldPlaylists - the old playlists to use as a reference * @param {Object[]} newPlaylists - the new playlists to update * @param {Object} timelineStarts - all timelineStarts seen in the stream to this point */ const updateSequenceNumbers = ({ oldPlaylists, newPlaylists, timelineStarts }) => { newPlaylists.forEach(playlist => { playlist.discontinuitySequence = timelineStarts.findIndex(function ({ timeline }) { return timeline === playlist.timeline; }); // Playlists NAMEs come from DASH Representation IDs, which are mandatory // (see ISO_23009-1-2012 5.3.5.2). // // If the same Representation existed in a prior Period, it will retain the same NAME. const oldPlaylist = findPlaylistWithName(oldPlaylists, playlist.attributes.NAME); if (!oldPlaylist) { // Since this is a new playlist, the media sequence values can start from 0 without // consequence. return; } // TODO better support for live SIDX // // As of this writing, mpd-parser does not support multiperiod SIDX (in live or VOD). // This is evident by a playlist only having a single SIDX reference. In a multiperiod // playlist there would need to be multiple SIDX references. In addition, live SIDX is // not supported when the SIDX properties change on refreshes. // // In the future, if support needs to be added, the merging logic here can be called // after SIDX references are resolved. For now, exit early to prevent exceptions being // thrown due to undefined references. if (playlist.sidx) { return; } // Since we don't yet support early available timelines, we don't need to support // playlists with no segments. const firstNewSegment = playlist.segments[0]; const oldMatchingSegmentIndex = oldPlaylist.segments.findIndex(function (oldSegment) { return Math.abs(oldSegment.presentationTime - firstNewSegment.presentationTime) < TIME_FUDGE; }); // No matching segment from the old playlist means the entire playlist was refreshed. // In this case the media sequence should account for this update, and the new segments // should be marked as discontinuous from the prior content, since the last prior // timeline was removed. if (oldMatchingSegmentIndex === -1) { updateMediaSequenceForPlaylist({ playlist, mediaSequence: oldPlaylist.mediaSequence + oldPlaylist.segments.length }); playlist.segments[0].discontinuity = true; playlist.discontinuityStarts.unshift(0); // No matching segment does not necessarily mean there's missing content. // // If the new playlist's timeline is the same as the last seen segment's timeline, // then a discontinuity can be added to identify that there's potentially missing // content. If there's no missing content, the discontinuity should still be rather // harmless. It's possible that if segment durations are accurate enough, that the // existence of a gap can be determined using the presentation times and durations, // but if the segment timing info is off, it may introduce more problems than simply // adding the discontinuity. // // If the new playlist's timeline is different from the last seen segment's timeline, // then a discontinuity can be added to identify that this is the first seen segment // of a new timeline. However, the logic at the start of this function that // determined the disconinuity sequence by timeline index is now off by one (the // discontinuity of the newest timeline hasn't yet fallen off the manifest...since // we added it), so the disconinuity sequence must be decremented. // // A period may also have a duration of zero, so the case of no segments is handled // here even though we don't yet support early available periods. if (!oldPlaylist.segments.length && playlist.timeline > oldPlaylist.timeline || oldPlaylist.segments.length && playlist.timeline > oldPlaylist.segments[oldPlaylist.segments.length - 1].timeline) { playlist.discontinuitySequence--; } return; } // If the first segment matched with a prior segment on a discontinuity (it's matching // on the first segment of a period), then the discontinuitySequence shouldn't be the // timeline's matching one, but instead should be the one prior, and the first segment // of the new manifest should be marked with a discontinuity. // // The reason for this special case is that discontinuity sequence shows how many // discontinuities have fallen off of the playlist, and discontinuities are marked on // the first segment of a new "timeline." Because of this, while DASH will retain that // Period while the "timeline" exists, HLS keeps track of it via the discontinuity // sequence, and that first segment is an indicator, but can be removed before that // timeline is gone. const oldMatchingSegment = oldPlaylist.segments[oldMatchingSegmentIndex]; if (oldMatchingSegment.discontinuity && !firstNewSegment.discontinuity) { firstNewSegment.discontinuity = true; playlist.discontinuityStarts.unshift(0); playlist.discontinuitySequence--; } updateMediaSequenceForPlaylist({ playlist, mediaSequence: oldPlaylist.segments[oldMatchingSegmentIndex].number }); }); }; /** * Given an old parsed manifest object and a new parsed manifest object, updates the * sequence and timing values within the new manifest to ensure that it lines up with the * old. * * @param {Array} oldManifest - the old main manifest object * @param {Array} newManifest - the new main manifest object * * @return {Object} the updated new manifest object */ const positionManifestOnTimeline = ({ oldManifest, newManifest }) => { // Starting from v4.1.2 of the IOP, section 4.4.3.3 states: // // "MPD@availabilityStartTime and Period@start shall not be changed over MPD updates." // // This was added from https://github.com/Dash-Industry-Forum/DASH-IF-IOP/issues/160 // // Because of this change, and the difficulty of supporting periods with changing start // times, periods with changing start times are not supported. This makes the logic much // simpler, since periods with the same start time can be considerred the same period // across refreshes. // // To give an example as to the difficulty of handling periods where the start time may // change, if a single period manifest is refreshed with another manifest with a single // period, and both the start and end times are increased, then the only way to determine // if it's a new period or an old one that has changed is to look through the segments of // each playlist and determine the presentation time bounds to find a match. In addition, // if the period start changed to exceed the old period end, then there would be no // match, and it would not be possible to determine whether the refreshed period is a new // one or the old one. const oldPlaylists = oldManifest.playlists.concat(getMediaGroupPlaylists(oldManifest)); const newPlaylists = newManifest.playlists.concat(getMediaGroupPlaylists(newManifest)); // Save all seen timelineStarts to the new manifest. Although this potentially means that // there's a "memory leak" in that it will never stop growing, in reality, only a couple // of properties are saved for each seen Period. Even long running live streams won't // generate too many Periods, unless the stream is watched for decades. In the future, // this can be optimized by mapping to discontinuity sequence numbers for each timeline, // but it may not become an issue, and the additional info can be useful for debugging. newManifest.timelineStarts = getUniqueTimelineStarts([oldManifest.timelineStarts, newManifest.timelineStarts]); updateSequenceNumbers({ oldPlaylists, newPlaylists, timelineStarts: newManifest.timelineStarts }); return newManifest; }; const generateSidxKey = sidx => sidx && sidx.uri + '-' + byteRangeToString(sidx.byterange); const mergeDiscontiguousPlaylists = playlists => { const mergedPlaylists = values(playlists.reduce((acc, playlist) => { // assuming playlist IDs are the same across periods // TODO: handle multiperiod where representation sets are not the same // across periods const name = playlist.attributes.id + (playlist.attributes.lang || ''); if (!acc[name]) { // First Period acc[name] = playlist; acc[name].attributes.timelineStarts = []; } else { // Subsequent Periods if (playlist.segments) { // first segment of subsequent periods signal a discontinuity if (playlist.segments[0]) { playlist.segments[0].discontinuity = true; } acc[name].segments.push(...playlist.segments); } // bubble up contentProtection, this assumes all DRM content // has the same contentProtection if (playlist.attributes.contentProtection) { acc[name].attributes.contentProtection = playlist.attributes.contentProtection; } } acc[name].attributes.timelineStarts.push({ // Although they represent the same number, it's important to have both to make it // compatible with HLS potentially having a similar attribute. start: playlist.attributes.periodStart, timeline: playlist.attributes.periodStart }); return acc; }, {})); return mergedPlaylists.map(playlist => { playlist.discontinuityStarts = findIndexes(playlist.segments || [], 'discontinuity'); return playlist; }); }; const addSidxSegmentsToPlaylist = (playlist, sidxMapping) => { const sidxKey = generateSidxKey(playlist.sidx); const sidxMatch = sidxKey && sidxMapping[sidxKey] && sidxMapping[sidxKey].sidx; if (sidxMatch) { addSidxSegmentsToPlaylist$1(playlist, sidxMatch, playlist.sidx.resolvedUri); } return playlist; }; const addSidxSegmentsToPlaylists = (playlists, sidxMapping = {}) => { if (!Object.keys(sidxMapping).length) { return playlists; } for (const i in playlists) { playlists[i] = addSidxSegmentsToPlaylist(playlists[i], sidxMapping); } return playlists; }; const formatAudioPlaylist = ({ attributes, segments, sidx, mediaSequence, discontinuitySequence, discontinuityStarts }, isAudioOnly) => { const playlist = { attributes: { NAME: attributes.id, BANDWIDTH: attributes.bandwidth, CODECS: attributes.codecs, ['PROGRAM-ID']: 1 }, uri: '', endList: attributes.type === 'static', timeline: attributes.periodStart, resolvedUri: '', targetDuration: attributes.duration, discontinuitySequence, discontinuityStarts, timelineStarts: attributes.timelineStarts, mediaSequence, segments }; if (attributes.contentProtection) { playlist.contentProtection = attributes.contentProtection; } if (sidx) { playlist.sidx = sidx; } if (isAudioOnly) { playlist.attributes.AUDIO = 'audio'; playlist.attributes.SUBTITLES = 'subs'; } return playlist; }; const formatVttPlaylist = ({ attributes, segments, mediaSequence, discontinuityStarts, discontinuitySequence }) => { if (typeof segments === 'undefined') { // vtt tracks may use single file in BaseURL segments = [{ uri: attributes.baseUrl, timeline: attributes.periodStart, resolvedUri: attributes.baseUrl || '', duration: attributes.sourceDuration, number: 0 }]; // targetDuration should be the same duration as the only segment attributes.duration = attributes.sourceDuration; } const m3u8Attributes = { NAME: attributes.id, BANDWIDTH: attributes.bandwidth, ['PROGRAM-ID']: 1 }; if (attributes.codecs) { m3u8Attributes.CODECS = attributes.codecs; } return { attributes: m3u8Attributes, uri: '', endList: attributes.type === 'static', timeline: attributes.periodStart, resolvedUri: attributes.baseUrl || '', targetDuration: attributes.duration, timelineStarts: attributes.timelineStarts, discontinuityStarts, discontinuitySequence, mediaSequence, segments }; }; const organizeAudioPlaylists = (playlists, sidxMapping = {}, isAudioOnly = false) => { let mainPlaylist; const formattedPlaylists = playlists.reduce((a, playlist) => { const role = playlist.attributes.role && playlist.attributes.role.value || ''; const language = playlist.attributes.lang || ''; let label = playlist.attributes.label || 'main'; if (language && !playlist.attributes.label) { const roleLabel = role ? ` (${role})` : ''; label = `${playlist.attributes.lang}${roleLabel}`; } if (!a[label]) { a[label] = { language, autoselect: true, default: role === 'main', playlists: [], uri: '' }; } const formatted = addSidxSegmentsToPlaylist(formatAudioPlaylist(playlist, isAudioOnly), sidxMapping); a[label].playlists.push(formatted); if (typeof mainPlaylist === 'undefined' && role === 'main') { mainPlaylist = playlist; mainPlaylist.default = true; } return a; }, {}); // if no playlists have role "main", mark the first as main if (!mainPlaylist) { const firstLabel = Object.keys(formattedPlaylists)[0]; formattedPlaylists[firstLabel].default = true; } return formattedPlaylists; }; const organizeVttPlaylists = (playlists, sidxMapping = {}) => { return playlists.reduce((a, playlist) => { const label = playlist.attributes.label || playlist.attributes.lang || 'text'; if (!a[label]) { a[label] = { language: label, default: false, autoselect: false, playlists: [], uri: '' }; } a[label].playlists.push(addSidxSegmentsToPlaylist(formatVttPlaylist(playlist), sidxMapping)); return a; }, {}); }; const organizeCaptionServices = captionServices => captionServices.reduce((svcObj, svc) => { if (!svc) { return svcObj; } svc.forEach(service => { const { channel, language } = service; svcObj[language] = { autoselect: false, default: false, instreamId: channel, language }; if (service.hasOwnProperty('aspectRatio')) { svcObj[language].aspectRatio = service.aspectRatio; } if (service.hasOwnProperty('easyReader')) { svcObj[language].easyReader = service.easyReader; } if (service.hasOwnProperty('3D')) { svcObj[language]['3D'] = service['3D']; } }); return svcObj; }, {}); const formatVideoPlaylist = ({ attributes, segments, sidx, discontinuityStarts }) => { const playlist = { attributes: { NAME: attributes.id, AUDIO: 'audio', SUBTITLES: 'subs', RESOLUTION: { width: attributes.width, height: attributes.height }, CODECS: attributes.codecs, BANDWIDTH: attributes.bandwidth, ['PROGRAM-ID']: 1 }, uri: '', endList: attributes.type === 'static', timeline: attributes.periodStart, resolvedUri: '', targetDuration: attributes.duration, discontinuityStarts, timelineStarts: attributes.timelineStarts, segments }; if (attributes.frameRate) { playlist.attributes['FRAME-RATE'] = attributes.frameRate; } if (attributes.contentProtection) { playlist.contentProtection = attributes.contentProtection; } if (sidx) { playlist.sidx = sidx; } return playlist; }; const videoOnly = ({ attributes }) => attributes.mimeType === 'video/mp4' || attributes.mimeType === 'video/webm' || attributes.contentType === 'video'; const audioOnly = ({ attributes }) => attributes.mimeType === 'audio/mp4' || attributes.mimeType === 'audio/webm' || attributes.contentType === 'audio'; const vttOnly = ({ attributes }) => attributes.mimeType === 'text/vtt' || attributes.contentType === 'text'; /** * Contains start and timeline properties denoting a timeline start. For DASH, these will * be the same number. * * @typedef {Object} TimelineStart * @property {number} start - the start time of the timeline * @property {number} timeline - the timeline number */ /** * Adds appropriate media and discontinuity sequence values to the segments and playlists. * * Throughout mpd-parser, the `number` attribute is used in relation to `startNumber`, a * DASH specific attribute used in constructing segment URI's from templates. However, from * an HLS perspective, the `number` attribute on a segment would be its `mediaSequence` * value, which should start at the original media sequence value (or 0) and increment by 1 * for each segment thereafter. Since DASH's `startNumber` values are independent per * period, it doesn't make sense to use it for `number`. Instead, assume everything starts * from a 0 mediaSequence value and increment from there. * * Note that VHS currently doesn't use the `number` property, but it can be helpful for * debugging and making sense of the manifest. * * For live playlists, to account for values increasing in manifests when periods are * removed on refreshes, merging logic should be used to update the numbers to their * appropriate values (to ensure they're sequential and increasing). * * @param {Object[]} playlists - the playlists to update * @param {TimelineStart[]} timelineStarts - the timeline starts for the manifest */ const addMediaSequenceValues = (playlists, timelineStarts) => { // increment all segments sequentially playlists.forEach(playlist => { playlist.mediaSequence = 0; playlist.discontinuitySequence = timelineStarts.findIndex(function ({ timeline }) { return timeline === playlist.timeline; }); if (!playlist.segments) { return; } playlist.segments.forEach((segment, index) => { segment.number = index; }); }); }; /** * Given a media group object, flattens all playlists within the media group into a single * array. * * @param {Object} mediaGroupObject - the media group object * * @return {Object[]} * The media group playlists */ const flattenMediaGroupPlaylists = mediaGroupObject => { if (!mediaGroupObject) { return []; } return Object.keys(mediaGroupObject).reduce((acc, label) => { const labelContents = mediaGroupObject[label]; return acc.concat(labelContents.playlists); }, []); }; const toM3u8 = ({ dashPlaylists, locations, sidxMapping = {}, previousManifest, eventStream }) => { if (!dashPlaylists.length) { return {}; } // grab all main manifest attributes const { sourceDuration: duration, type, suggestedPresentationDelay, minimumUpdatePeriod } = dashPlaylists[0].attributes; const videoPlaylists = mergeDiscontiguousPlaylists(dashPlaylists.filter(videoOnly)).map(formatVideoPlaylist); const audioPlaylists = mergeDiscontiguousPlaylists(dashPlaylists.filter(audioOnly)); const vttPlaylists = mergeDiscontiguousPlaylists(dashPlaylists.filter(vttOnly)); const captions = dashPlaylists.map(playlist => playlist.attributes.captionServices).filter(Boolean); const manifest = { allowCache: true, discontinuityStarts: [], segments: [], endList: true, mediaGroups: { AUDIO: {}, VIDEO: {}, ['CLOSED-CAPTIONS']: {}, SUBTITLES: {} }, uri: '', duration, playlists: addSidxSegmentsToPlaylists(videoPlaylists, sidxMapping) }; if (minimumUpdatePeriod >= 0) { manifest.minimumUpdatePeriod = minimumUpdatePeriod * 1000; } if (locations) { manifest.locations = locations; } if (type === 'dynamic') { manifest.suggestedPresentationDelay = suggestedPresentationDelay; } if (eventStream && eventStream.length > 0) { manifest.eventStream = eventStream; } const isAudioOnly = manifest.playlists.length === 0; const organizedAudioGroup = audioPlaylists.length ? organizeAudioPlaylists(audioPlaylists, sidxMapping, isAudioOnly) : null; const organizedVttGroup = vttPlaylists.length ? organizeVttPlaylists(vttPlaylists, sidxMapping) : null; const formattedPlaylists = videoPlaylists.concat(flattenMediaGroupPlaylists(organizedAudioGroup), flattenMediaGroupPlaylists(organizedVttGroup)); const playlistTimelineStarts = formattedPlaylists.map(({ timelineStarts }) => timelineStarts); manifest.timelineStarts = getUniqueTimelineStarts(playlistTimelineStarts); addMediaSequenceValues(formattedPlaylists, manifest.timelineStarts); if (organizedAudioGroup) { manifest.mediaGroups.AUDIO.audio = organizedAudioGroup; } if (organizedVttGroup) { manifest.mediaGroups.SUBTITLES.subs = organizedVttGroup; } if (captions.length) { manifest.mediaGroups['CLOSED-CAPTIONS'].cc = organizeCaptionServices(captions); } if (previousManifest) { return positionManifestOnTimeline({ oldManifest: previousManifest, newManifest: manifest }); } return manifest; }; /** * Calculates the R (repetition) value for a live stream (for the final segment * in a manifest where the r value is negative 1) * * @param {Object} attributes * Object containing all inherited attributes from parent elements with attribute * names as keys * @param {number} time * current time (typically the total time up until the final segment) * @param {number} duration * duration property for the given * * @return {number} * R value to reach the end of the given period */ const getLiveRValue = (attributes, time, duration) => { const { NOW, clientOffset, availabilityStartTime, timescale = 1, periodStart = 0, minimumUpdatePeriod = 0 } = attributes; const now = (NOW + clientOffset) / 1000; const periodStartWC = availabilityStartTime + periodStart; const periodEndWC = now + minimumUpdatePeriod; const periodDuration = periodEndWC - periodStartWC; return Math.ceil((periodDuration * timescale - time) / duration); }; /** * Uses information provided by SegmentTemplate.SegmentTimeline to determine segment * timing and duration * * @param {Object} attributes * Object containing all inherited attributes from parent elements with attribute * names as keys * @param {Object[]} segmentTimeline * List of objects representing the attributes of each S element contained within * * @return {{number: number, duration: number, time: number, timeline: number}[]} * List of Objects with segment timing and duration info */ const parseByTimeline = (attributes, segmentTimeline) => { const { type, minimumUpdatePeriod = 0, media = '', sourceDuration, timescale = 1, startNumber = 1, periodStart: timeline } = attributes; const segments = []; let time = -1; for (let sIndex = 0; sIndex < segmentTimeline.length; sIndex++) { const S = segmentTimeline[sIndex]; const duration = S.d; const repeat = S.r || 0; const segmentTime = S.t || 0; if (time < 0) { // first segment time = segmentTime; } if (segmentTime && segmentTime > time) { // discontinuity // TODO: How to handle this type of discontinuity // timeline++ here would treat it like HLS discontuity and content would // get appended without gap // E.G. // // // // // would have $Time$ values of [0, 1, 2, 5] // should this be appened at time positions [0, 1, 2, 3],(#EXT-X-DISCONTINUITY) // or [0, 1, 2, gap, gap, 5]? (#EXT-X-GAP) // does the value of sourceDuration consider this when calculating arbitrary // negative @r repeat value? // E.G. Same elements as above with this added at the end // // with a sourceDuration of 10 // Would the 2 gaps be included in the time duration calculations resulting in // 8 segments with $Time$ values of [0, 1, 2, 5, 6, 7, 8, 9] or 10 segments // with $Time$ values of [0, 1, 2, 5, 6, 7, 8, 9, 10, 11] ? time = segmentTime; } let count; if (repeat < 0) { const nextS = sIndex + 1; if (nextS === segmentTimeline.length) { // last segment if (type === 'dynamic' && minimumUpdatePeriod > 0 && media.indexOf('$Number$') > 0) { count = getLiveRValue(attributes, time, duration); } else { // TODO: This may be incorrect depending on conclusion of TODO above count = (sourceDuration * timescale - time) / duration; } } else { count = (segmentTimeline[nextS].t - time) / duration; } } else { count = repeat + 1; } const end = startNumber + segments.length + count; let number = startNumber + segments.length; while (number < end) { segments.push({ number, duration: duration / timescale, time, timeline }); time += duration; number++; } } return segments; }; const identifierPattern = /\$([A-z]*)(?:(%0)([0-9]+)d)?\$/g; /** * Replaces template identifiers with corresponding values. To be used as the callback * for String.prototype.replace * * @name replaceCallback * @function * @param {string} match * Entire match of identifier * @param {string} identifier * Name of matched identifier * @param {string} format * Format tag string. Its presence indicates that padding is expected * @param {string} width * Desired length of the replaced value. Values less than this width shall be left * zero padded * @return {string} * Replacement for the matched identifier */ /** * Returns a function to be used as a callback for String.prototype.replace to replace * template identifiers * * @param {Obect} values * Object containing values that shall be used to replace known identifiers * @param {number} values.RepresentationID * Value of the Representation@id attribute * @param {number} values.Number * Number of the corresponding segment * @param {number} values.Bandwidth * Value of the Representation@bandwidth attribute. * @param {number} values.Time * Timestamp value of the corresponding segment * @return {replaceCallback} * Callback to be used with String.prototype.replace to replace identifiers */ const identifierReplacement = values => (match, identifier, format, width) => { if (match === '$$') { // escape sequence return '$'; } if (typeof values[identifier] === 'undefined') { return match; } const value = '' + values[identifier]; if (identifier === 'RepresentationID') { // Format tag shall not be present with RepresentationID return value; } if (!format) { width = 1; } else { width = parseInt(width, 10); } if (value.length >= width) { return value; } return `${new Array(width - value.length + 1).join('0')}${value}`; }; /** * Constructs a segment url from a template string * * @param {string} url * Template string to construct url from * @param {Obect} values * Object containing values that shall be used to replace known identifiers * @param {number} values.RepresentationID * Value of the Representation@id attribute * @param {number} values.Number * Number of the corresponding segment * @param {number} values.Bandwidth * Value of the Representation@bandwidth attribute. * @param {number} values.Time * Timestamp value of the corresponding segment * @return {string} * Segment url with identifiers replaced */ const constructTemplateUrl = (url, values) => url.replace(identifierPattern, identifierReplacement(values)); /** * Generates a list of objects containing timing and duration information about each * segment needed to generate segment uris and the complete segment object * * @param {Object} attributes * Object containing all inherited attributes from parent elements with attribute * names as keys * @param {Object[]|undefined} segmentTimeline * List of objects representing the attributes of each S element contained within * the SegmentTimeline element * @return {{number: number, duration: number, time: number, timeline: number}[]} * List of Objects with segment timing and duration info */ const parseTemplateInfo = (attributes, segmentTimeline) => { if (!attributes.duration && !segmentTimeline) { // if neither @duration or SegmentTimeline are present, then there shall be exactly // one media segment return [{ number: attributes.startNumber || 1, duration: attributes.sourceDuration, time: 0, timeline: attributes.periodStart }]; } if (attributes.duration) { return parseByDuration(attributes); } return parseByTimeline(attributes, segmentTimeline); }; /** * Generates a list of segments using information provided by the SegmentTemplate element * * @param {Object} attributes * Object containing all inherited attributes from parent elements with attribute * names as keys * @param {Object[]|undefined} segmentTimeline * List of objects representing the attributes of each S element contained within * the SegmentTimeline element * @return {Object[]} * List of segment objects */ const segmentsFromTemplate = (attributes, segmentTimeline) => { const templateValues = { RepresentationID: attributes.id, Bandwidth: attributes.bandwidth || 0 }; const { initialization = { sourceURL: '', range: '' } } = attributes; const mapSegment = urlTypeToSegment({ baseUrl: attributes.baseUrl, source: constructTemplateUrl(initialization.sourceURL, templateValues), range: initialization.range }); const segments = parseTemplateInfo(attributes, segmentTimeline); return segments.map(segment => { templateValues.Number = segment.number; templateValues.Time = segment.time; const uri = constructTemplateUrl(attributes.media || '', templateValues); // See DASH spec section 5.3.9.2.2 // - if timescale isn't present on any level, default to 1. const timescale = attributes.timescale || 1; // - if presentationTimeOffset isn't present on any level, default to 0 const presentationTimeOffset = attributes.presentationTimeOffset || 0; const presentationTime = // Even if the @t attribute is not specified for the segment, segment.time is // calculated in mpd-parser prior to this, so it's assumed to be available. attributes.periodStart + (segment.time - presentationTimeOffset) / timescale; const map = { uri, timeline: segment.timeline, duration: segment.duration, resolvedUri: resolveUrl(attributes.baseUrl || '', uri), map: mapSegment, number: segment.number, presentationTime }; return map; }); }; /** * Converts a (of type URLType from the DASH spec 5.3.9.2 Table 14) * to an object that matches the output of a segment in videojs/mpd-parser * * @param {Object} attributes * Object containing all inherited attributes from parent elements with attribute * names as keys * @param {Object} segmentUrl * node to translate into a segment object * @return {Object} translated segment object */ const SegmentURLToSegmentObject = (attributes, segmentUrl) => { const { baseUrl, initialization = {} } = attributes; const initSegment = urlTypeToSegment({ baseUrl, source: initialization.sourceURL, range: initialization.range }); const segment = urlTypeToSegment({ baseUrl, source: segmentUrl.media, range: segmentUrl.mediaRange }); segment.map = initSegment; return segment; }; /** * Generates a list of segments using information provided by the SegmentList element * SegmentList (DASH SPEC Section 5.3.9.3.2) contains a set of nodes. Each * node should be translated into segment. * * @param {Object} attributes * Object containing all inherited attributes from parent elements with attribute * names as keys * @param {Object[]|undefined} segmentTimeline * List of objects representing the attributes of each S element contained within * the SegmentTimeline element * @return {Object.} list of segments */ const segmentsFromList = (attributes, segmentTimeline) => { const { duration, segmentUrls = [], periodStart } = attributes; // Per spec (5.3.9.2.1) no way to determine segment duration OR // if both SegmentTimeline and @duration are defined, it is outside of spec. if (!duration && !segmentTimeline || duration && segmentTimeline) { throw new Error(errors.SEGMENT_TIME_UNSPECIFIED); } const segmentUrlMap = segmentUrls.map(segmentUrlObject => SegmentURLToSegmentObject(attributes, segmentUrlObject)); let segmentTimeInfo; if (duration) { segmentTimeInfo = parseByDuration(attributes); } if (segmentTimeline) { segmentTimeInfo = parseByTimeline(attributes, segmentTimeline); } const segments = segmentTimeInfo.map((segmentTime, index) => { if (segmentUrlMap[index]) { const segment = segmentUrlMap[index]; // See DASH spec section 5.3.9.2.2 // - if timescale isn't present on any level, default to 1. const timescale = attributes.timescale || 1; // - if presentationTimeOffset isn't present on any level, default to 0 const presentationTimeOffset = attributes.presentationTimeOffset || 0; segment.timeline = segmentTime.timeline; segment.duration = segmentTime.duration; segment.number = segmentTime.number; segment.presentationTime = periodStart + (segmentTime.time - presentationTimeOffset) / timescale; return segment; } // Since we're mapping we should get rid of any blank segments (in case // the given SegmentTimeline is handling for more elements than we have // SegmentURLs for). }).filter(segment => segment); return segments; }; const generateSegments = ({ attributes, segmentInfo }) => { let segmentAttributes; let segmentsFn; if (segmentInfo.template) { segmentsFn = segmentsFromTemplate; segmentAttributes = merge(attributes, segmentInfo.template); } else if (segmentInfo.base) { segmentsFn = segmentsFromBase; segmentAttributes = merge(attributes, segmentInfo.base); } else if (segmentInfo.list) { segmentsFn = segmentsFromList; segmentAttributes = merge(attributes, segmentInfo.list); } const segmentsInfo = { attributes }; if (!segmentsFn) { return segmentsInfo; } const segments = segmentsFn(segmentAttributes, segmentInfo.segmentTimeline); // The @duration attribute will be used to determin the playlist's targetDuration which // must be in seconds. Since we've generated the segment list, we no longer need // @duration to be in @timescale units, so we can convert it here. if (segmentAttributes.duration) { const { duration, timescale = 1 } = segmentAttributes; segmentAttributes.duration = duration / timescale; } else if (segments.length) { // if there is no @duration attribute, use the largest segment duration as // as target duration segmentAttributes.duration = segments.reduce((max, segment) => { return Math.max(max, Math.ceil(segment.duration)); }, 0); } else { segmentAttributes.duration = 0; } segmentsInfo.attributes = segmentAttributes; segmentsInfo.segments = segments; // This is a sidx box without actual segment information if (segmentInfo.base && segmentAttributes.indexRange) { segmentsInfo.sidx = segments[0]; segmentsInfo.segments = []; } return segmentsInfo; }; const toPlaylists = representations => representations.map(generateSegments); const findChildren = (element, name) => from(element.childNodes).filter(({ tagName }) => tagName === name); const getContent = element => element.textContent.trim(); /** * Converts the provided string that may contain a division operation to a number. * * @param {string} value - the provided string value * * @return {number} the parsed string value */ const parseDivisionValue = value => { return parseFloat(value.split('/').reduce((prev, current) => prev / current)); }; const parseDuration = str => { const SECONDS_IN_YEAR = 365 * 24 * 60 * 60; const SECONDS_IN_MONTH = 30 * 24 * 60 * 60; const SECONDS_IN_DAY = 24 * 60 * 60; const SECONDS_IN_HOUR = 60 * 60; const SECONDS_IN_MIN = 60; // P10Y10M10DT10H10M10.1S const durationRegex = /P(?:(\d*)Y)?(?:(\d*)M)?(?:(\d*)D)?(?:T(?:(\d*)H)?(?:(\d*)M)?(?:([\d.]*)S)?)?/; const match = durationRegex.exec(str); if (!match) { return 0; } const [year, month, day, hour, minute, second] = match.slice(1); return parseFloat(year || 0) * SECONDS_IN_YEAR + parseFloat(month || 0) * SECONDS_IN_MONTH + parseFloat(day || 0) * SECONDS_IN_DAY + parseFloat(hour || 0) * SECONDS_IN_HOUR + parseFloat(minute || 0) * SECONDS_IN_MIN + parseFloat(second || 0); }; const parseDate = str => { // Date format without timezone according to ISO 8601 // YYY-MM-DDThh:mm:ss.ssssss const dateRegex = /^\d+-\d+-\d+T\d+:\d+:\d+(\.\d+)?$/; // If the date string does not specifiy a timezone, we must specifiy UTC. This is // expressed by ending with 'Z' if (dateRegex.test(str)) { str += 'Z'; } return Date.parse(str); }; const parsers = { /** * Specifies the duration of the entire Media Presentation. Format is a duration string * as specified in ISO 8601 * * @param {string} value * value of attribute as a string * @return {number} * The duration in seconds */ mediaPresentationDuration(value) { return parseDuration(value); }, /** * Specifies the Segment availability start time for all Segments referred to in this * MPD. For a dynamic manifest, it specifies the anchor for the earliest availability * time. Format is a date string as specified in ISO 8601 * * @param {string} value * value of attribute as a string * @return {number} * The date as seconds from unix epoch */ availabilityStartTime(value) { return parseDate(value) / 1000; }, /** * Specifies the smallest period between potential changes to the MPD. Format is a * duration string as specified in ISO 8601 * * @param {string} value * value of attribute as a string * @return {number} * The duration in seconds */ minimumUpdatePeriod(value) { return parseDuration(value); }, /** * Specifies the suggested presentation delay. Format is a * duration string as specified in ISO 8601 * * @param {string} value * value of attribute as a string * @return {number} * The duration in seconds */ suggestedPresentationDelay(value) { return parseDuration(value); }, /** * specifices the type of mpd. Can be either "static" or "dynamic" * * @param {string} value * value of attribute as a string * * @return {string} * The type as a string */ type(value) { return value; }, /** * Specifies the duration of the smallest time shifting buffer for any Representation * in the MPD. Format is a duration string as specified in ISO 8601 * * @param {string} value * value of attribute as a string * @return {number} * The duration in seconds */ timeShiftBufferDepth(value) { return parseDuration(value); }, /** * Specifies the PeriodStart time of the Period relative to the availabilityStarttime. * Format is a duration string as specified in ISO 8601 * * @param {string} value * value of attribute as a string * @return {number} * The duration in seconds */ start(value) { return parseDuration(value); }, /** * Specifies the width of the visual presentation * * @param {string} value * value of attribute as a string * @return {number} * The parsed width */ width(value) { return parseInt(value, 10); }, /** * Specifies the height of the visual presentation * * @param {string} value * value of attribute as a string * @return {number} * The parsed height */ height(value) { return parseInt(value, 10); }, /** * Specifies the bitrate of the representation * * @param {string} value * value of attribute as a string * @return {number} * The parsed bandwidth */ bandwidth(value) { return parseInt(value, 10); }, /** * Specifies the frame rate of the representation * * @param {string} value * value of attribute as a string * @return {number} * The parsed frame rate */ frameRate(value) { return parseDivisionValue(value); }, /** * Specifies the number of the first Media Segment in this Representation in the Period * * @param {string} value * value of attribute as a string * @return {number} * The parsed number */ startNumber(value) { return parseInt(value, 10); }, /** * Specifies the timescale in units per seconds * * @param {string} value * value of attribute as a string * @return {number} * The parsed timescale */ timescale(value) { return parseInt(value, 10); }, /** * Specifies the presentationTimeOffset. * * @param {string} value * value of the attribute as a string * * @return {number} * The parsed presentationTimeOffset */ presentationTimeOffset(value) { return parseInt(value, 10); }, /** * Specifies the constant approximate Segment duration * NOTE: The element also contains an @duration attribute. This duration * specifies the duration of the Period. This attribute is currently not * supported by the rest of the parser, however we still check for it to prevent * errors. * * @param {string} value * value of attribute as a string * @return {number} * The parsed duration */ duration(value) { const parsedValue = parseInt(value, 10); if (isNaN(parsedValue)) { return parseDuration(value); } return parsedValue; }, /** * Specifies the Segment duration, in units of the value of the @timescale. * * @param {string} value * value of attribute as a string * @return {number} * The parsed duration */ d(value) { return parseInt(value, 10); }, /** * Specifies the MPD start time, in @timescale units, the first Segment in the series * starts relative to the beginning of the Period * * @param {string} value * value of attribute as a string * @return {number} * The parsed time */ t(value) { return parseInt(value, 10); }, /** * Specifies the repeat count of the number of following contiguous Segments with the * same duration expressed by the value of @d * * @param {string} value * value of attribute as a string * @return {number} * The parsed number */ r(value) { return parseInt(value, 10); }, /** * Specifies the presentationTime. * * @param {string} value * value of the attribute as a string * * @return {number} * The parsed presentationTime */ presentationTime(value) { return parseInt(value, 10); }, /** * Default parser for all other attributes. Acts as a no-op and just returns the value * as a string * * @param {string} value * value of attribute as a string * @return {string} * Unparsed value */ DEFAULT(value) { return value; } }; /** * Gets all the attributes and values of the provided node, parses attributes with known * types, and returns an object with attribute names mapped to values. * * @param {Node} el * The node to parse attributes from * @return {Object} * Object with all attributes of el parsed */ const parseAttributes = el => { if (!(el && el.attributes)) { return {}; } return from(el.attributes).reduce((a, e) => { const parseFn = parsers[e.name] || parsers.DEFAULT; a[e.name] = parseFn(e.value); return a; }, {}); }; const keySystemsMap = { 'urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b': 'org.w3.clearkey', 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed': 'com.widevine.alpha', 'urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95': 'com.microsoft.playready', 'urn:uuid:f239e769-efa3-4850-9c16-a903c6932efb': 'com.adobe.primetime' }; /** * Builds a list of urls that is the product of the reference urls and BaseURL values * * @param {string[]} referenceUrls * List of reference urls to resolve to * @param {Node[]} baseUrlElements * List of BaseURL nodes from the mpd * @return {string[]} * List of resolved urls */ const buildBaseUrls = (referenceUrls, baseUrlElements) => { if (!baseUrlElements.length) { return referenceUrls; } return flatten(referenceUrls.map(function (reference) { return baseUrlElements.map(function (baseUrlElement) { return resolveUrl(reference, getContent(baseUrlElement)); }); })); }; /** * Contains all Segment information for its containing AdaptationSet * * @typedef {Object} SegmentInformation * @property {Object|undefined} template * Contains the attributes for the SegmentTemplate node * @property {Object[]|undefined} segmentTimeline * Contains a list of atrributes for each S node within the SegmentTimeline node * @property {Object|undefined} list * Contains the attributes for the SegmentList node * @property {Object|undefined} base * Contains the attributes for the SegmentBase node */ /** * Returns all available Segment information contained within the AdaptationSet node * * @param {Node} adaptationSet * The AdaptationSet node to get Segment information from * @return {SegmentInformation} * The Segment information contained within the provided AdaptationSet */ const getSegmentInformation = adaptationSet => { const segmentTemplate = findChildren(adaptationSet, 'SegmentTemplate')[0]; const segmentList = findChildren(adaptationSet, 'SegmentList')[0]; const segmentUrls = segmentList && findChildren(segmentList, 'SegmentURL').map(s => merge({ tag: 'SegmentURL' }, parseAttributes(s))); const segmentBase = findChildren(adaptationSet, 'SegmentBase')[0]; const segmentTimelineParentNode = segmentList || segmentTemplate; const segmentTimeline = segmentTimelineParentNode && findChildren(segmentTimelineParentNode, 'SegmentTimeline')[0]; const segmentInitializationParentNode = segmentList || segmentBase || segmentTemplate; const segmentInitialization = segmentInitializationParentNode && findChildren(segmentInitializationParentNode, 'Initialization')[0]; // SegmentTemplate is handled slightly differently, since it can have both // @initialization and an node. @initialization can be templated, // while the node can have a url and range specified. If the has // both @initialization and an subelement we opt to override with // the node, as this interaction is not defined in the spec. const template = segmentTemplate && parseAttributes(segmentTemplate); if (template && segmentInitialization) { template.initialization = segmentInitialization && parseAttributes(segmentInitialization); } else if (template && template.initialization) { // If it is @initialization we convert it to an object since this is the format that // later functions will rely on for the initialization segment. This is only valid // for template.initialization = { sourceURL: template.initialization }; } const segmentInfo = { template, segmentTimeline: segmentTimeline && findChildren(segmentTimeline, 'S').map(s => parseAttributes(s)), list: segmentList && merge(parseAttributes(segmentList), { segmentUrls, initialization: parseAttributes(segmentInitialization) }), base: segmentBase && merge(parseAttributes(segmentBase), { initialization: parseAttributes(segmentInitialization) }) }; Object.keys(segmentInfo).forEach(key => { if (!segmentInfo[key]) { delete segmentInfo[key]; } }); return segmentInfo; }; /** * Contains Segment information and attributes needed to construct a Playlist object * from a Representation * * @typedef {Object} RepresentationInformation * @property {SegmentInformation} segmentInfo * Segment information for this Representation * @property {Object} attributes * Inherited attributes for this Representation */ /** * Maps a Representation node to an object containing Segment information and attributes * * @name inheritBaseUrlsCallback * @function * @param {Node} representation * Representation node from the mpd * @return {RepresentationInformation} * Representation information needed to construct a Playlist object */ /** * Returns a callback for Array.prototype.map for mapping Representation nodes to * Segment information and attributes using inherited BaseURL nodes. * * @param {Object} adaptationSetAttributes * Contains attributes inherited by the AdaptationSet * @param {string[]} adaptationSetBaseUrls * Contains list of resolved base urls inherited by the AdaptationSet * @param {SegmentInformation} adaptationSetSegmentInfo * Contains Segment information for the AdaptationSet * @return {inheritBaseUrlsCallback} * Callback map function */ const inheritBaseUrls = (adaptationSetAttributes, adaptationSetBaseUrls, adaptationSetSegmentInfo) => representation => { const repBaseUrlElements = findChildren(representation, 'BaseURL'); const repBaseUrls = buildBaseUrls(adaptationSetBaseUrls, repBaseUrlElements); const attributes = merge(adaptationSetAttributes, parseAttributes(representation)); const representationSegmentInfo = getSegmentInformation(representation); return repBaseUrls.map(baseUrl => { return { segmentInfo: merge(adaptationSetSegmentInfo, representationSegmentInfo), attributes: merge(attributes, { baseUrl }) }; }); }; /** * Tranforms a series of content protection nodes to * an object containing pssh data by key system * * @param {Node[]} contentProtectionNodes * Content protection nodes * @return {Object} * Object containing pssh data by key system */ const generateKeySystemInformation = contentProtectionNodes => { return contentProtectionNodes.reduce((acc, node) => { const attributes = parseAttributes(node); // Although it could be argued that according to the UUID RFC spec the UUID string (a-f chars) should be generated // as a lowercase string it also mentions it should be treated as case-insensitive on input. Since the key system // UUIDs in the keySystemsMap are hardcoded as lowercase in the codebase there isn't any reason not to do // .toLowerCase() on the input UUID string from the manifest (at least I could not think of one). if (attributes.schemeIdUri) { attributes.schemeIdUri = attributes.schemeIdUri.toLowerCase(); } const keySystem = keySystemsMap[attributes.schemeIdUri]; if (keySystem) { acc[keySystem] = { attributes }; const psshNode = findChildren(node, 'cenc:pssh')[0]; if (psshNode) { const pssh = getContent(psshNode); acc[keySystem].pssh = pssh && decodeB64ToUint8Array(pssh); } } return acc; }, {}); }; // defined in ANSI_SCTE 214-1 2016 const parseCaptionServiceMetadata = service => { // 608 captions if (service.schemeIdUri === 'urn:scte:dash:cc:cea-608:2015') { const values = typeof service.value !== 'string' ? [] : service.value.split(';'); return values.map(value => { let channel; let language; // default language to value language = value; if (/^CC\d=/.test(value)) { [channel, language] = value.split('='); } else if (/^CC\d$/.test(value)) { channel = value; } return { channel, language }; }); } else if (service.schemeIdUri === 'urn:scte:dash:cc:cea-708:2015') { const values = typeof service.value !== 'string' ? [] : service.value.split(';'); return values.map(value => { const flags = { // service or channel number 1-63 'channel': undefined, // language is a 3ALPHA per ISO 639.2/B // field is required 'language': undefined, // BIT 1/0 or ? // default value is 1, meaning 16:9 aspect ratio, 0 is 4:3, ? is unknown 'aspectRatio': 1, // BIT 1/0 // easy reader flag indicated the text is tailed to the needs of beginning readers // default 0, or off 'easyReader': 0, // BIT 1/0 // If 3d metadata is present (CEA-708.1) then 1 // default 0 '3D': 0 }; if (/=/.test(value)) { const [channel, opts = ''] = value.split('='); flags.channel = channel; flags.language = value; opts.split(',').forEach(opt => { const [name, val] = opt.split(':'); if (name === 'lang') { flags.language = val; // er for easyReadery } else if (name === 'er') { flags.easyReader = Number(val); // war for wide aspect ratio } else if (name === 'war') { flags.aspectRatio = Number(val); } else if (name === '3D') { flags['3D'] = Number(val); } }); } else { flags.language = value; } if (flags.channel) { flags.channel = 'SERVICE' + flags.channel; } return flags; }); } }; /** * A map callback that will parse all event stream data for a collection of periods * DASH ISO_IEC_23009 5.10.2.2 * https://dashif-documents.azurewebsites.net/Events/master/event.html#mpd-event-timing * * @param {PeriodInformation} period object containing necessary period information * @return a collection of parsed eventstream event objects */ const toEventStream = period => { // get and flatten all EventStreams tags and parse attributes and children return flatten(findChildren(period.node, 'EventStream').map(eventStream => { const eventStreamAttributes = parseAttributes(eventStream); const schemeIdUri = eventStreamAttributes.schemeIdUri; // find all Events per EventStream tag and map to return objects return findChildren(eventStream, 'Event').map(event => { const eventAttributes = parseAttributes(event); const presentationTime = eventAttributes.presentationTime || 0; const timescale = eventStreamAttributes.timescale || 1; const duration = eventAttributes.duration || 0; const start = presentationTime / timescale + period.attributes.start; return { schemeIdUri, value: eventStreamAttributes.value, id: eventAttributes.id, start, end: start + duration / timescale, messageData: getContent(event) || eventAttributes.messageData, contentEncoding: eventStreamAttributes.contentEncoding, presentationTimeOffset: eventStreamAttributes.presentationTimeOffset || 0 }; }); })); }; /** * Maps an AdaptationSet node to a list of Representation information objects * * @name toRepresentationsCallback * @function * @param {Node} adaptationSet * AdaptationSet node from the mpd * @return {RepresentationInformation[]} * List of objects containing Representaion information */ /** * Returns a callback for Array.prototype.map for mapping AdaptationSet nodes to a list of * Representation information objects * * @param {Object} periodAttributes * Contains attributes inherited by the Period * @param {string[]} periodBaseUrls * Contains list of resolved base urls inherited by the Period * @param {string[]} periodSegmentInfo * Contains Segment Information at the period level * @return {toRepresentationsCallback} * Callback map function */ const toRepresentations = (periodAttributes, periodBaseUrls, periodSegmentInfo) => adaptationSet => { const adaptationSetAttributes = parseAttributes(adaptationSet); const adaptationSetBaseUrls = buildBaseUrls(periodBaseUrls, findChildren(adaptationSet, 'BaseURL')); const role = findChildren(adaptationSet, 'Role')[0]; const roleAttributes = { role: parseAttributes(role) }; let attrs = merge(periodAttributes, adaptationSetAttributes, roleAttributes); const accessibility = findChildren(adaptationSet, 'Accessibility')[0]; const captionServices = parseCaptionServiceMetadata(parseAttributes(accessibility)); if (captionServices) { attrs = merge(attrs, { captionServices }); } const label = findChildren(adaptationSet, 'Label')[0]; if (label && label.childNodes.length) { const labelVal = label.childNodes[0].nodeValue.trim(); attrs = merge(attrs, { label: labelVal }); } const contentProtection = generateKeySystemInformation(findChildren(adaptationSet, 'ContentProtection')); if (Object.keys(contentProtection).length) { attrs = merge(attrs, { contentProtection }); } const segmentInfo = getSegmentInformation(adaptationSet); const representations = findChildren(adaptationSet, 'Representation'); const adaptationSetSegmentInfo = merge(periodSegmentInfo, segmentInfo); return flatten(representations.map(inheritBaseUrls(attrs, adaptationSetBaseUrls, adaptationSetSegmentInfo))); }; /** * Contains all period information for mapping nodes onto adaptation sets. * * @typedef {Object} PeriodInformation * @property {Node} period.node * Period node from the mpd * @property {Object} period.attributes * Parsed period attributes from node plus any added */ /** * Maps a PeriodInformation object to a list of Representation information objects for all * AdaptationSet nodes contained within the Period. * * @name toAdaptationSetsCallback * @function * @param {PeriodInformation} period * Period object containing necessary period information * @param {number} periodStart * Start time of the Period within the mpd * @return {RepresentationInformation[]} * List of objects containing Representaion information */ /** * Returns a callback for Array.prototype.map for mapping Period nodes to a list of * Representation information objects * * @param {Object} mpdAttributes * Contains attributes inherited by the mpd * @param {string[]} mpdBaseUrls * Contains list of resolved base urls inherited by the mpd * @return {toAdaptationSetsCallback} * Callback map function */ const toAdaptationSets = (mpdAttributes, mpdBaseUrls) => (period, index) => { const periodBaseUrls = buildBaseUrls(mpdBaseUrls, findChildren(period.node, 'BaseURL')); const periodAttributes = merge(mpdAttributes, { periodStart: period.attributes.start }); if (typeof period.attributes.duration === 'number') { periodAttributes.periodDuration = period.attributes.duration; } const adaptationSets = findChildren(period.node, 'AdaptationSet'); const periodSegmentInfo = getSegmentInformation(period.node); return flatten(adaptationSets.map(toRepresentations(periodAttributes, periodBaseUrls, periodSegmentInfo))); }; /** * Gets Period@start property for a given period. * * @param {Object} options * Options object * @param {Object} options.attributes * Period attributes * @param {Object} [options.priorPeriodAttributes] * Prior period attributes (if prior period is available) * @param {string} options.mpdType * The MPD@type these periods came from * @return {number|null} * The period start, or null if it's an early available period or error */ const getPeriodStart = ({ attributes, priorPeriodAttributes, mpdType }) => { // Summary of period start time calculation from DASH spec section 5.3.2.1 // // A period's start is the first period's start + time elapsed after playing all // prior periods to this one. Periods continue one after the other in time (without // gaps) until the end of the presentation. // // The value of Period@start should be: // 1. if Period@start is present: value of Period@start // 2. if previous period exists and it has @duration: previous Period@start + // previous Period@duration // 3. if this is first period and MPD@type is 'static': 0 // 4. in all other cases, consider the period an "early available period" (note: not // currently supported) // (1) if (typeof attributes.start === 'number') { return attributes.start; } // (2) if (priorPeriodAttributes && typeof priorPeriodAttributes.start === 'number' && typeof priorPeriodAttributes.duration === 'number') { return priorPeriodAttributes.start + priorPeriodAttributes.duration; } // (3) if (!priorPeriodAttributes && mpdType === 'static') { return 0; } // (4) // There is currently no logic for calculating the Period@start value if there is // no Period@start or prior Period@start and Period@duration available. This is not made // explicit by the DASH interop guidelines or the DASH spec, however, since there's // nothing about any other resolution strategies, it's implied. Thus, this case should // be considered an early available period, or error, and null should suffice for both // of those cases. return null; }; /** * Traverses the mpd xml tree to generate a list of Representation information objects * that have inherited attributes from parent nodes * * @param {Node} mpd * The root node of the mpd * @param {Object} options * Available options for inheritAttributes * @param {string} options.manifestUri * The uri source of the mpd * @param {number} options.NOW * Current time per DASH IOP. Default is current time in ms since epoch * @param {number} options.clientOffset * Client time difference from NOW (in milliseconds) * @return {RepresentationInformation[]} * List of objects containing Representation information */ const inheritAttributes = (mpd, options = {}) => { const { manifestUri = '', NOW = Date.now(), clientOffset = 0 } = options; const periodNodes = findChildren(mpd, 'Period'); if (!periodNodes.length) { throw new Error(errors.INVALID_NUMBER_OF_PERIOD); } const locations = findChildren(mpd, 'Location'); const mpdAttributes = parseAttributes(mpd); const mpdBaseUrls = buildBaseUrls([manifestUri], findChildren(mpd, 'BaseURL')); // See DASH spec section 5.3.1.2, Semantics of MPD element. Default type to 'static'. mpdAttributes.type = mpdAttributes.type || 'static'; mpdAttributes.sourceDuration = mpdAttributes.mediaPresentationDuration || 0; mpdAttributes.NOW = NOW; mpdAttributes.clientOffset = clientOffset; if (locations.length) { mpdAttributes.locations = locations.map(getContent); } const periods = []; // Since toAdaptationSets acts on individual periods right now, the simplest approach to // adding properties that require looking at prior periods is to parse attributes and add // missing ones before toAdaptationSets is called. If more such properties are added, it // may be better to refactor toAdaptationSets. periodNodes.forEach((node, index) => { const attributes = parseAttributes(node); // Use the last modified prior period, as it may contain added information necessary // for this period. const priorPeriod = periods[index - 1]; attributes.start = getPeriodStart({ attributes, priorPeriodAttributes: priorPeriod ? priorPeriod.attributes : null, mpdType: mpdAttributes.type }); periods.push({ node, attributes }); }); return { locations: mpdAttributes.locations, representationInfo: flatten(periods.map(toAdaptationSets(mpdAttributes, mpdBaseUrls))), eventStream: flatten(periods.map(toEventStream)) }; }; const stringToMpdXml = manifestString => { if (manifestString === '') { throw new Error(errors.DASH_EMPTY_MANIFEST); } const parser = new DOMParser(); let xml; let mpd; try { xml = parser.parseFromString(manifestString, 'application/xml'); mpd = xml && xml.documentElement.tagName === 'MPD' ? xml.documentElement : null; } catch (e) {// ie 11 throwsw on invalid xml } if (!mpd || mpd && mpd.getElementsByTagName('parsererror').length > 0) { throw new Error(errors.DASH_INVALID_XML); } return mpd; }; /** * Parses the manifest for a UTCTiming node, returning the nodes attributes if found * * @param {string} mpd * XML string of the MPD manifest * @return {Object|null} * Attributes of UTCTiming node specified in the manifest. Null if none found */ const parseUTCTimingScheme = mpd => { const UTCTimingNode = findChildren(mpd, 'UTCTiming')[0]; if (!UTCTimingNode) { return null; } const attributes = parseAttributes(UTCTimingNode); switch (attributes.schemeIdUri) { case 'urn:mpeg:dash:utc:http-head:2014': case 'urn:mpeg:dash:utc:http-head:2012': attributes.method = 'HEAD'; break; 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': attributes.method = 'GET'; break; case 'urn:mpeg:dash:utc:direct:2014': case 'urn:mpeg:dash:utc:direct:2012': attributes.method = 'DIRECT'; attributes.value = Date.parse(attributes.value); break; case 'urn:mpeg:dash:utc:http-ntp:2014': case 'urn:mpeg:dash:utc:ntp:2014': case 'urn:mpeg:dash:utc:sntp:2014': default: throw new Error(errors.UNSUPPORTED_UTC_TIMING_SCHEME); } return attributes; }; const VERSION = version; /* * Given a DASH manifest string and options, parses the DASH manifest into an object in the * form outputed by m3u8-parser and accepted by videojs/http-streaming. * * For live DASH manifests, if `previousManifest` is provided in options, then the newly * parsed DASH manifest will have its media sequence and discontinuity sequence values * updated to reflect its position relative to the prior manifest. * * @param {string} manifestString - the DASH manifest as a string * @param {options} [options] - any options * * @return {Object} the manifest object */ const parse = (manifestString, options = {}) => { const parsedManifestInfo = inheritAttributes(stringToMpdXml(manifestString), options); const playlists = toPlaylists(parsedManifestInfo.representationInfo); return toM3u8({ dashPlaylists: playlists, locations: parsedManifestInfo.locations, sidxMapping: options.sidxMapping, previousManifest: options.previousManifest, eventStream: parsedManifestInfo.eventStream }); }; /** * Parses the manifest for a UTCTiming node, returning the nodes attributes if found * * @param {string} manifestString * XML string of the MPD manifest * @return {Object|null} * Attributes of UTCTiming node specified in the manifest. Null if none found */ const parseUTCTiming = manifestString => parseUTCTimingScheme(stringToMpdXml(manifestString)); export { VERSION, addSidxSegmentsToPlaylist$1 as addSidxSegmentsToPlaylist, generateSidxKey, inheritAttributes, parse, parseUTCTiming, stringToMpdXml, toM3u8, toPlaylists };