diff --git a/README.md b/README.md index 05279cac..41282649 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,19 @@ const manifestUri = 'https://example.com/dash.xml'; const res = await fetch(manifestUri); const manifest = await res.text(); -var parsedManifest = mpdParser.parse(manifest, { manifestUri }); +const { manifest: parsedManifest } = mpdParser.parse(manifest, { manifestUri }); +``` + +If dealing with a live stream, then on subsequent calls to parse, the last parsed manifest +object should be provided as an option to `parse` using the `lastMpd` option: + +```js +const parsedManifest = mpdParser.parse(manifest, { manifestUri, lastMpd }); ``` ### Parsed Output -The parser ouputs a plain javascript object with the following structure: +The parser ouputs a parsed manifest as a plain javascript object with the following structure: ```js Manifest { @@ -61,6 +68,8 @@ Manifest { Manifest } ], + playlistToExclude: [], + mediaGroupPlaylistsToExclude: [] mediaGroups: { AUDIO: { 'GROUP-ID': { @@ -113,6 +122,12 @@ Manifest { } ``` +For live DASH playlists where a `lastMpd` object was provided, the returned manifest object +may contain `playlistsToExclude` and `mediaGroupPlaylistsToExclude`. These are arrays +containing playlists that were found in the `lastMpd` (if provided) but could not be +matched in the latest MPD. To continue playback uninterrupted, they should be excluded by +the playback engine. + ## Including the Parser To include mpd-parser on your website or web application, use any of the following methods. @@ -124,8 +139,8 @@ This is the simplest case. Get the script in whatever way you prefer and include ```html ``` @@ -134,16 +149,15 @@ This is the simplest case. Get the script in whatever way you prefer and include When using with Browserify, install mpd-parser via npm and `require` the parser as you would any other module. ```js -var mpdParser = require('mpd-parser'); - -var parsedManifest = mpdParser.parse(manifest, manifestUrl); +const mpdParser = require('mpd-parser'); +const parsedManifest = mpdParser.parse(manifest, { manifestUri }); ``` With ES6: ```js import { parse } from 'mpd-parser'; -const parsedManifest = parse(manifest, manifestUrl); +const parsedManifest = parse(manifest, { manifestUri }); ``` ### RequireJS/AMD @@ -152,7 +166,7 @@ When using with RequireJS (or another AMD library), get the script in whatever w ```js require(['mpd-parser'], function(mpdParser) { - var parsedManifest = mpdParser.parse(manifest, manifestUrl); + const parsedManifest = mpdParser.parse(manifest, { manifestUri }); }); ``` diff --git a/src/index.js b/src/index.js index 31924d86..b4cdff8c 100644 --- a/src/index.js +++ b/src/index.js @@ -4,15 +4,33 @@ import { toPlaylists } from './toPlaylists'; import { inheritAttributes } from './inheritAttributes'; import { stringToMpdXml } from './stringToMpdXml'; import { parseUTCTimingScheme } from './parseUTCTimingScheme'; -import {addSidxSegmentsToPlaylist} from './segment/segmentBase.js'; +import { addSidxSegmentsToPlaylist } from './segment/segmentBase.js'; const VERSION = version; +/* + * Given a DASH manifest string and options, parses the DASH manifest into a manifest + * object. + * + * For live DASH manifests, if `lastMpd` is provided in options, then the newly parsed + * DASH manifest will have its media 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(playlists, parsedManifestInfo.locations, options.sidxMapping); + return toM3u8({ + dashPlaylists: playlists, + locations: parsedManifestInfo.locations, + sidxMapping: options.sidxMapping, + lastMpd: options.lastMpd + }); }; /** diff --git a/src/inheritAttributes.js b/src/inheritAttributes.js index 067fb8dc..ad645f18 100644 --- a/src/inheritAttributes.js +++ b/src/inheritAttributes.js @@ -1,4 +1,3 @@ -import window from 'global/window'; import { flatten } from './utils/list'; import { merge } from './utils/object'; import { findChildren, getContent } from './utils/xml'; @@ -386,9 +385,20 @@ export const toRepresentations = */ export const toAdaptationSets = (mpdAttributes, mpdBaseUrls) => (period, index) => { const periodBaseUrls = buildBaseUrls(mpdBaseUrls, findChildren(period.node, 'BaseURL')); - const parsedPeriodId = parseInt(period.attributes.id, 10); - // fallback to mapping index if Period@id is not a number - const periodIndex = window.isNaN(parsedPeriodId) ? index : parsedPeriodId; + // periodIndex used to always use Period@id, and was used as the period's timeline (the + // discontinuity sequence for the manifest). This helped address dynamic playlists, as + // Period@id was often a number, and it often increased. However, the DASH specification + // states that Period@id is a string, and says nothing about increasing values. This led + // to problems with dynamic playlists where the Period@id would not be a number and + // would not increase for each added period. In addition, when it was a number, it + // potentially increased dramatically, making the manifest object harder to understand. + // + // Now, the index of the period is used, and for overlapping values on refreshes, the + // playlist merging logic should handle any increase. This should handle all the + // necessary cases we used to support, but may be problematic when it comes time to + // handle multiperiod live playlists with SIDX segments (using SegmentBase), as the + // merging logic can't yet handle that case. This should be a TODO for the future. + const periodIndex = index; const periodAttributes = merge(mpdAttributes, { periodIndex, periodStart: period.attributes.start diff --git a/src/playlist-merge.js b/src/playlist-merge.js new file mode 100644 index 00000000..33c3650a --- /dev/null +++ b/src/playlist-merge.js @@ -0,0 +1,586 @@ +import { forEachMediaGroup } from '@videojs/vhs-utils/es/media-groups'; +import { + findIndex, + findIndexes, + includes +} from './utils/list'; + +const SUPPORTED_MEDIA_TYPES = ['AUDIO', 'SUBTITLES']; + +/** + * 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 + */ +export const findPlaylistWithName = (playlists, name) => { + for (let i = 0; i < playlists.length; i++) { + if (playlists[i].attributes.NAME === name) { + return playlists[i]; + } + } + + return null; +}; + +/** + * Finds the media group playlist with the matching NAME attribute. + * + * @param {Object} config options object + * @param {string} config.playlistName the playlist NAME attribute to search for + * @param {string} config.type the media group type + * @param {string} config.group the media group...group + * @param {string} config.label the media group label + * @param {Object} config.manifest the main manifest object + * + * @return {Object|null} the matching media group playlist object, or null + */ +export const findMediaGroupPlaylistWithName = ({ + playlistName, + type, + group, + label, + manifest +}) => { + const typeObject = manifest.mediaGroups[type]; + const properties = typeObject && typeObject[group] && typeObject[group][label]; + + if (!properties) { + return null; + } + + return properties.playlists.length && + findPlaylistWithName(properties.playlists, playlistName); +}; + +/** + * Returns any old playlists that are no longer available in the new playlists. + * + * @param {Object} config options object + * @param {Array} config.oldPlaylists the old playlists to search within + * @param {Array} config.newPlaylists the new playlists to check for + * + * @return {Array} any playlists not available in new playlists array + */ +export const getRemovedPlaylists = ({ oldPlaylists, newPlaylists }) => { + // Playlists NAMEs come from DASH Representation IDs, which are mandatory + // (see ISO_23009-1-2012 5.3.5.2) + const oldNameToPlaylistMap = oldPlaylists.reduce((acc, playlist) => { + acc[playlist.attributes.NAME] = playlist; + return acc; + }, {}); + const newNameToPlaylistMap = newPlaylists.reduce((acc, playlist) => { + acc[playlist.attributes.NAME] = playlist; + return acc; + }, {}); + + return Object.keys(oldNameToPlaylistMap).reduce((acc, oldPlaylistName) => { + const oldPlaylist = oldNameToPlaylistMap[oldPlaylistName]; + const matchingNewPlaylist = newNameToPlaylistMap[oldPlaylistName]; + + if (!matchingNewPlaylist) { + acc.push(oldPlaylist); + } + + return acc; + }, []); +}; + +/** + * Returns any old media group playlists that are no longer available in the new media + * group playlists. + * + * @param {Object} config options object + * @param {Object} config.oldManifest the old main manifest object + * @param {Object} config.newManifest the new main manifest object + * + * @return {Array} removed media group playlist objects + */ +export const getRemovedMediaGroupPlaylists = ({ oldManifest, newManifest }) => { + const removedMediaGroupPlaylists = []; + + forEachMediaGroup(oldManifest, SUPPORTED_MEDIA_TYPES, (properties, type, group, label) => { + const oldPlaylists = properties.playlists || []; + + oldPlaylists.forEach((oldPlaylist) => { + const newPlaylist = findMediaGroupPlaylistWithName({ + // Playlists NAMEs come from DASH Representation IDs, which are mandatory + // (see ISO_23009-1-2012 5.3.5.2) + playlistName: oldPlaylist.attributes.NAME, + type, + group, + label, + manifest: newManifest + }); + + if (!newPlaylist) { + removedMediaGroupPlaylists.push({ + type, + group, + label, + playlist: oldPlaylist + }); + } + }); + }); + + return removedMediaGroupPlaylists; +}; + +/** + * Returns any new playlists that do not account for all timelines (where all timelines is + * the max number of timelines seen across all of the passed in playlists). + * + * @param {Array} playlists the playlists to look through + * + * @return {Array} any playlists that do not account for all timelines + */ +export const getIncompletePlaylists = (playlists) => { + const timelines = {}; + const playlistToTimelines = {}; + + playlists.forEach((playlist) => { + if (!playlist.segments) { + return; + } + + const playlistTimelines = playlist.segments.reduce((acc, segment) => { + acc[segment.timeline] = true; + timelines[segment.timeline] = true; + + return acc; + }, {}); + + // All playlists should have a unique ID sourced from the required attribute + // Representation@id from the MPD. + playlistToTimelines[playlist.attributes.NAME] = playlistTimelines; + }, {}); + + const numNewTimelines = Object.keys(timelines).length; + + if (numNewTimelines === 0) { + return []; + } + + return playlists.filter((playlist) => + Object.keys(playlistToTimelines[playlist.attributes.NAME]).length < numNewTimelines); +}; + +/** + * Gets a flattened array of media group playlists. + * + * @param {Object} manifest the main manifest object + * + * @return {Array} the media group playlists + */ +export const getMediaGroupPlaylists = (manifest) => { + let mediaGroupPlaylists = []; + + forEachMediaGroup(manifest, SUPPORTED_MEDIA_TYPES, (properties, type, group, label) => { + mediaGroupPlaylists = mediaGroupPlaylists.concat(properties.playlists || []); + }); + + return mediaGroupPlaylists; +}; + +/** + * @typedef {Object} MediaGroupPlaylistIdentificationObject + * @property {string} type - the media group type + * @property {string} group - the media group...group + * @property {string} label - the media group label + * @property {Object} playlist - the playlist within the group + */ + +/** + * Given a list of playlists and a manifest object, returns an array of objects with all + * of the necessary media group properties to identify it within a manifest. + * + * @param {Object} config options object + * @param {Array} config.playlists the playlist objects + * @param {Object} config.manifest the main manifest object + * + * @return {MediaGroupPlaylistIdentificationObject[]} + * media group playlist identification objects + */ +export const getMediaGroupPlaylistIdentificationObjects = ({ playlists, manifest }) => { + if (!playlists.length) { + return []; + } + + const idObjects = []; + + forEachMediaGroup(manifest, SUPPORTED_MEDIA_TYPES, (properties, type, group, label) => { + properties.playlists.forEach((playlist) => { + if (includes(playlists, playlist)) { + idObjects.push({ + type, + group, + label, + playlist + }); + } + }); + }); + + return idObjects; +}; + +/** + * Goes through the provided segments and updates the appropriate sequence and timeline + * related attributes. + * + * @param {Object} config options object + * @param {Array} config.segments the segments to update + * @param {number} config.mediaSequenceStart the mediaSequence number to start with + * @param {string} config.timelineStart the timeline number to start with + */ +export const repositionSegmentsOnTimeline = ({ + segments, + mediaSequenceStart, + timelineStart +}) => { + let currentMediaSequence = mediaSequenceStart; + let currentTimeline = timelineStart; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + // timelineStart should already account for the first discontinuity + if (i > 0 && segment.discontinuity) { + currentTimeline++; + } + segment.number = currentMediaSequence; + segment.timeline = currentTimeline; + currentMediaSequence++; + } +}; + +/** + * Given a new playlist and an old matching playlist from a prior refresh, updates the new + * playlist's sequence and timing values (including segments) to ensure that the new + * playlist reflects its relative position to the old one. + * + * @param {Object} oldPlaylist the old playlist to base the updates on + * @param {Object} newPlaylist the new playlist to update + */ +export const positionPlaylistOnTimeline = (oldPlaylist, newPlaylist) => { + const oldSegments = oldPlaylist.segments || []; + const newSegments = newPlaylist.segments || []; + // At the time of writing, mpd-parser did not add discontinuitySequence. This code + // allows for it to exist or to not exist, and should handle both appropriately. + const oldDiscontinuitySequence = oldPlaylist.discontinuitySequence || 0; + + // If the prior playlist had no segments, it's likely either the start of the stream or + // a break in the stream. Either way, the best approach is to consider the new segments + // a discontinuous region of the media and to continue as if the new playlist is a + // continuation of the prior stream. + if (oldSegments.length === 0) { + newPlaylist.mediaSequence = oldPlaylist.mediaSequence; + // The discontinuity sequence should remain the same. Accounting for removed + // discontinuities occurs on playlist updates where segments are removed. + newPlaylist.discontinuitySequence = oldDiscontinuitySequence; + // The timeline should remain the same until a new segment is added. + newPlaylist.timeline = oldPlaylist.timeline + (newPlaylist.segments.length ? 1 : 0); + + if (newSegments.length > 0) { + newSegments[0].discontinuity = true; + } + + repositionSegmentsOnTimeline({ + segments: newSegments, + mediaSequenceStart: newPlaylist.mediaSequence, + timelineStart: newPlaylist.timeline + }); + + newPlaylist.discontinuityStarts = findIndexes(newSegments, 'discontinuity'); + return; + } + + // If there are no new segments then the stream is either reaching an end or a gap. + // The media sequence should be updated accordingly, but the timeline should remain the + // same, as it will be updated once segments are added. + if (newSegments.length === 0) { + newPlaylist.mediaSequence = oldPlaylist.mediaSequence + oldSegments.length; + newPlaylist.discontinuitySequence = + oldDiscontinuitySequence + findIndexes(oldSegments, 'discontinuity').length; + newPlaylist.timeline = oldSegments.length ? + oldSegments[oldSegments.length - 1].timeline : oldPlaylist.timeline; + newPlaylist.discontinuityStarts = []; + return; + } + + // From here on both old and new playlist have segments. + // + // To update the new playlist, we must position the old and the new together. The + // simplest approach is to try to match an exact segment and adjust timing values from + // there. However, this will not always work. It's possible that different periods will + // reuse the same segment, and it's also possible that refreshes may change certain + // segment properties (e.g., the URI). + // + // Because of these limitations, the best approach for matching segments is by finding + // a segment with a matching presentation time. These values should not change on + // refreshes. + const firstNewSegment = newSegments[0]; + const oldMatchingSegmentIndex = findIndex(oldSegments, (oldSegment) => + // allow one 60fps frame as leniency (arbitrarily chosen) + Math.abs(oldSegment.presentationTime - firstNewSegment.presentationTime) < (1 / 60)); + const lastOldSegment = oldSegments[oldSegments.length - 1]; + + // 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 period + // was removed. + if (oldMatchingSegmentIndex < 0) { + // It's possible that segments were missed on refresh. However, since we don't have any + // access to these prior segments, continue as if the newest segment is the next media + // sequence in the stream. At the time of writing, this shouldn't have further + // ramifications for playback, as it will be marked as discontinuous with the prior + // segments and should offset based on the last buffered values rather than based on + // exact DASH manifest values. + newPlaylist.mediaSequence = oldPlaylist.mediaSequence + oldSegments.length; + newPlaylist.timeline = lastOldSegment.timeline + 1; + newPlaylist.discontinuitySequence = + oldDiscontinuitySequence + findIndexes(oldSegments, 'discontinuity').length; + firstNewSegment.discontinuity = true; + + repositionSegmentsOnTimeline({ + segments: newSegments, + mediaSequenceStart: newPlaylist.mediaSequence, + timelineStart: newPlaylist.timeline + }); + + newPlaylist.discontinuityStarts = findIndexes(newSegments, 'discontinuity'); + return; + } + + // There's a matching segment. Use it to update new playlist segment sequence values. + // Note that the matching segment in the new playlist should always be index 0. + let oldSegmentIndex = oldMatchingSegmentIndex; + let newSegmentIndex = 0; + + // Check if new manifest is start of a period + if (oldSegments[oldSegmentIndex].discontinuity) { + firstNewSegment.discontinuity = true; + } + + while (oldSegmentIndex < oldSegments.length) { + const oldSegment = oldSegments[oldSegmentIndex]; + const newSegment = newSegments[newSegmentIndex]; + + // It's possible that segments were removed from the end of the manifest. This may or + // may not be legal, as per the spec, but it is seen in practice. It's possible a + // situation like this could happen if there are multiple servers providing the + // manifest and they aren't perfectly in-sync. If the case is encountered, just break + // from this loop. + if (!newSegment) { + break; + } + + newSegment.number = oldSegment.number; + newSegment.timeline = oldSegment.timeline; + + oldSegmentIndex++; + newSegmentIndex++; + } + + newPlaylist.mediaSequence = firstNewSegment.number; + newPlaylist.discontinuitySequence = oldDiscontinuitySequence + + // since only some of the discontinuities were removed from the playlist, only account + // for those + findIndexes(oldSegments.slice(0, oldMatchingSegmentIndex), 'discontinuity').length; + newPlaylist.timeline = firstNewSegment.timeline; + newPlaylist.discontinuityStarts = findIndexes(newSegments, 'discontinuity'); + + if (newSegments.length === 1) { + return; + } + + while (newSegmentIndex > 0 && newSegmentIndex < newSegments.length) { + const priorSegment = newSegments[newSegmentIndex - 1]; + const segment = newSegments[newSegmentIndex]; + + segment.number = priorSegment.number + 1; + segment.timeline = priorSegment.timeline + (segment.discontinuity ? 1 : 0); + + newSegmentIndex++; + } +}; + +/** + * Given new playlists and old playlists from a prior refresh, updates the new playlists's + * sequence and timing values (including segments) to ensure that the new playlists + * reflect their relative positions to the old ones. + * + * Note that this function assumes that the old matching playlists (based on playlist + * NAME) exist. + * + * @param {Array} oldPlaylists the old playlists to base the updates on + * @param {Array} newPlaylists the new playlists to update + */ +export const positionPlaylistsOnTimeline = ({ oldPlaylists, newPlaylists }) => { + newPlaylists.forEach((newPlaylist) => { + const playlistName = newPlaylist.attributes.NAME; + const oldPlaylist = findPlaylistWithName(oldPlaylists, playlistName); + + if (!oldPlaylist) { + return; + } + + positionPlaylistOnTimeline(oldPlaylist, newPlaylist); + }); +}; + +/** + * Given a new manifest and an old manifest from a prior refresh, updates the new media + * group playlists' sequence and timing values (including segments) to ensure that the new + * media group playlists reflect their relative positions to the old ones. + * + * Note that this function assumes that the old matching media group playlists (based on + * playlist NAME) exist. + * + * @param {Object} oldManifest the old main manifest object + * @param {Object} newManifest the new main manifest object + */ +export const positionMediaGroupPlaylistsOnTimeline = ({ oldManifest, newManifest }) => { + forEachMediaGroup(newManifest, SUPPORTED_MEDIA_TYPES, (properties, type, group, label) => { + const newPlaylists = properties.playlists || []; + + newPlaylists.forEach((newPlaylist) => { + const oldPlaylist = findMediaGroupPlaylistWithName({ + // Playlists NAMEs come from DASH Representation IDs, which are mandatory + // (see ISO_23009-1-2012 5.3.5.2) + playlistName: newPlaylist.attributes.NAME, + type, + group, + label, + manifest: oldManifest + }); + + if (!oldPlaylist) { + return; + } + + positionPlaylistOnTimeline(oldPlaylist, newPlaylist); + }); + }); +}; + +/** + * Removes matching media group playlists from the manifest. This function will also + * remove any labels and groups made empty after removal. + * + * @param {Object} manifest - the manifest object + * @param {Object[]} playlists - the media group playlists to remove + */ +export const removeMediaGroupPlaylists = ({ manifest, playlists }) => { + getMediaGroupPlaylistIdentificationObjects({ playlists, manifest }).forEach((idObject) => { + const mediaGroup = manifest.mediaGroups[idObject.type][idObject.group][idObject.label]; + + mediaGroup.playlists = + mediaGroup.playlists.filter((playlist) => playlist !== idObject.playlist); + + if (mediaGroup.playlists.length === 0) { + delete manifest.mediaGroups[idObject.type][idObject.group][idObject.label]; + } + + if (Object.keys(manifest.mediaGroups[idObject.type][idObject.group]).length === 0) { + delete manifest.mediaGroups[idObject.type][idObject.group]; + } + }); +}; + +/** + * 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 manifest object + */ +export const positionManifestOnTimeline = ({ oldManifest, newManifest }) => { + const oldPlaylists = oldManifest.playlists; + const newPlaylists = newManifest.playlists; + + const incompletePlaylists = getIncompletePlaylists(newPlaylists); + const removedPlaylists = getRemovedPlaylists({ oldPlaylists, newPlaylists }); + // to get added, reverse the sources + const addedPlaylists = getRemovedPlaylists({ + oldPlaylists: newPlaylists, + newPlaylists: oldPlaylists + }); + + const newMediaGroupPlaylists = getMediaGroupPlaylists(newManifest); + const incompleteMediaGroupPlaylists = getIncompletePlaylists(newMediaGroupPlaylists); + const incompleteMediaGroupPlaylistIdObjects = + getMediaGroupPlaylistIdentificationObjects({ + playlists: incompleteMediaGroupPlaylists, + manifest: newManifest + }); + const removedMediaGroupPlaylists = getRemovedMediaGroupPlaylists({ + oldManifest, + newManifest + }); + // to get added, reverse the sources + const addedMediaGroupPlaylists = getRemovedMediaGroupPlaylists({ + oldManifest: newManifest, + newManifest: oldManifest + }); + + // TODO: handle (instead of remove) playlists that only exist for specific timelines + // (i.e., incomplete) + // + // Playlists can be removed from the manifest on period boundaries. For now, we don't + // have a good way of handling playlists that exist for only part of a stream. Due to + // that, the best course of action is to not include them. In the future, we may want + // to have a better way of handling playlists that only represent specific timelines in + // the stream. + // + // If the incomplete playlists are newly added playlists, and on a future refresh will + // be complete (for instance, if old periods/timelines are removed on future refreshes + // and the new playlist exists for periods/timelines after a certain point) then it will + // be available once it is no longer incomplete. + newManifest.playlists = + newManifest.playlists.filter((playlist) => !includes(incompletePlaylists, playlist)); + removeMediaGroupPlaylists({ + manifest: newManifest, + playlists: incompleteMediaGroupPlaylists + }); + + const oldMediaGroupPlaylists = getMediaGroupPlaylists(oldManifest); + + // Only remove newly added playlists if a playlist existed before. This allows for + // streams to start empty of playlists and add a first one. However, the behavior may + // break if a stream ever removes all playlists and then starts up again adding new + // playlists. + if (oldMediaGroupPlaylists.length > 0 || oldPlaylists.length > 0) { + // TODO: handle (instead of remove) playlists that are added + // + // Right now, if a playlist is added, it is simply removed from the returned manifest + // object. This keeps the merging logic simple, as if there isn't an old playlist to + // compare with, then it is harder to position it. However, this can be resolved with + // matching presentation times in another playlist (or matching presentation time + // ranges within timelines) and sequencing from there. + newManifest.playlists = + newManifest.playlists.filter((playlist) => !includes(addedPlaylists, playlist)); + removeMediaGroupPlaylists({ + manifest: newManifest, + playlists: addedMediaGroupPlaylists.map((idObject) => idObject.playlist) + }); + } + + positionPlaylistsOnTimeline({ + oldPlaylists: oldManifest.playlists, + newPlaylists: newManifest.playlists + }); + positionMediaGroupPlaylistsOnTimeline({ oldManifest, newManifest }); + + newManifest.playlistsToExclude = removedPlaylists.concat(incompletePlaylists); + newManifest.mediaGroupPlaylistsToExclude = + removedMediaGroupPlaylists.concat(incompleteMediaGroupPlaylistIdObjects); + + return newManifest; +}; diff --git a/src/segment/segmentBase.js b/src/segment/segmentBase.js index eb7397e0..f6aa3f3d 100644 --- a/src/segment/segmentBase.js +++ b/src/segment/segmentBase.js @@ -18,7 +18,8 @@ export const segmentsFromBase = (attributes) => { initialization = {}, sourceDuration, indexRange = '', - duration + duration, + periodStart } = attributes; // base url is required for SegmentBase to work, per spec (Section 5.3.9.2.1) @@ -50,7 +51,9 @@ export const segmentsFromBase = (attributes) => { segment.timeline = 0; } - // This is used for mediaSequence + // Since there's only one segment in the period, the segment's presentation time is + // the same as the period start. + segment.presentationTime = periodStart; segment.number = 0; return [segment]; @@ -82,6 +85,10 @@ export const addSidxSegmentsToPlaylist = (playlist, sidx, baseUrl) => { const mediaReferences = sidx.references.filter(r => r.referenceType !== 1); const segments = []; const type = playlist.endList ? 'static' : 'dynamic'; + // Right now we don't support multiperiod sidx, so we can assume the presentation time + // starts at 0. In the future, when multiperiod sidx is handled, the presentation time + // will need to account for each period start. + let presentationTime = 0; // firstOffset is the offset from the end of the sidx box let startIndex = sidxEnd + sidx.firstOffset; @@ -103,6 +110,10 @@ export const addSidxSegmentsToPlaylist = (playlist, sidx, baseUrl) => { timeline, // this is used in parseByDuration periodIndex: timeline, + // Technically the presentationTime isn't the period start, but is instead the + // current presentation time for each segment that is added. The segmentsFromBase + // function expects period start, so it's renamed to match. + periodStart: presentationTime, duration, sourceDuration, indexRange, @@ -117,6 +128,7 @@ export const addSidxSegmentsToPlaylist = (playlist, sidx, baseUrl) => { segments.push(segment); startIndex += size; + presentationTime += duration / timescale; } playlist.segments = segments; diff --git a/src/toM3u8.js b/src/toM3u8.js index e3e1e62a..63d68614 100644 --- a/src/toM3u8.js +++ b/src/toM3u8.js @@ -2,6 +2,7 @@ import { values } from './utils/object'; import { findIndexes } from './utils/list'; import { addSidxSegmentsToPlaylist as addSidxSegmentsToPlaylist_ } from './segment/segmentBase'; import { byteRangeToString } from './segment/urlType'; +import { positionManifestOnTimeline } from './playlist-merge'; export const generateSidxKey = (sidx) => sidx && sidx.uri + '-' + byteRangeToString(sidx.byterange); @@ -66,7 +67,13 @@ export const addSidxSegmentsToPlaylists = (playlists, sidxMapping = {}) => { return playlists; }; -export const formatAudioPlaylist = ({ attributes, segments, sidx }, isAudioOnly) => { +export const formatAudioPlaylist = ({ + attributes, + segments, + sidx, + mediaSequence, + discontinuitySequence +}, isAudioOnly) => { const playlist = { attributes: { NAME: attributes.id, @@ -79,8 +86,9 @@ export const formatAudioPlaylist = ({ attributes, segments, sidx }, isAudioOnly) timeline: attributes.periodIndex, resolvedUri: '', targetDuration: attributes.duration, - segments, - mediaSequence: segments.length ? segments[0].number : 1 + discontinuitySequence, + mediaSequence, + segments }; if (attributes.contentProtection) { @@ -99,7 +107,12 @@ export const formatAudioPlaylist = ({ attributes, segments, sidx }, isAudioOnly) return playlist; }; -export const formatVttPlaylist = ({ attributes, segments }) => { +export const formatVttPlaylist = ({ + attributes, + segments, + mediaSequence, + discontinuitySequence +}) => { if (typeof segments === 'undefined') { // vtt tracks may use single file in BaseURL segments = [{ @@ -107,6 +120,18 @@ export const formatVttPlaylist = ({ attributes, segments }) => { timeline: attributes.periodIndex, resolvedUri: attributes.baseUrl || '', duration: attributes.sourceDuration, + // From DASH IOP v4.3 section 6.4.5: + // + // Only one file for the full period is permitted, practically limiting this use + // case to non-live content. + // + // Such external files are assumed to have a timeline aligned with the Period, + // so that TTML time 00:00:00.000 corresponds to the start of the Period. The + // presentation time offset is expected to be not presented, and if present, + // expected to be ignored by the DASH client. + // + // The same applies to side-loaded WebVTT files. + presentationTime: attributes.periodStart, number: 0 }]; // targetDuration should be the same duration as the only segment @@ -129,8 +154,9 @@ export const formatVttPlaylist = ({ attributes, segments }) => { timeline: attributes.periodIndex, resolvedUri: attributes.baseUrl || '', targetDuration: attributes.duration, - segments, - mediaSequence: segments.length ? segments[0].number : 1 + discontinuitySequence, + mediaSequence, + segments }; }; @@ -253,8 +279,7 @@ export const formatVideoPlaylist = ({ attributes, segments, sidx }) => { timeline: attributes.periodIndex, resolvedUri: '', targetDuration: attributes.duration, - segments, - mediaSequence: segments.length ? segments[0].number : 1 + segments }; if (attributes.contentProtection) { @@ -275,7 +300,41 @@ const audioOnly = ({ attributes }) => const vttOnly = ({ attributes }) => attributes.mimeType === 'text/vtt' || attributes.contentType === 'text'; -export const toM3u8 = (dashPlaylists, locations, sidxMapping = {}) => { +/** + * 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). + */ +export const addMediaSequenceValues = (playlists) => { + // increment all segments sequentially + playlists.forEach((playlist) => { + playlist.mediaSequence = 0; + playlist.discontinuitySequence = 0; + + if (!playlist.segments) { + return; + } + + for (let i = 0; i < playlist.segments.length; i++) { + playlist.segments[i].number = i; + } + }); +}; + +export const toM3u8 = ({ dashPlaylists, locations, sidxMapping = {}, lastMpd }) => { if (!dashPlaylists.length) { return {}; } @@ -293,6 +352,8 @@ export const toM3u8 = (dashPlaylists, locations, sidxMapping = {}) => { const vttPlaylists = dashPlaylists.filter(vttOnly); const captions = dashPlaylists.map((playlist) => playlist.attributes.captionServices).filter(Boolean); + [videoPlaylists, audioPlaylists, vttPlaylists].forEach(addMediaSequenceValues); + const manifest = { allowCache: true, discontinuityStarts: [], @@ -336,5 +397,12 @@ export const toM3u8 = (dashPlaylists, locations, sidxMapping = {}) => { manifest.mediaGroups['CLOSED-CAPTIONS'].cc = organizeCaptionServices(captions); } + if (lastMpd) { + return positionManifestOnTimeline({ + oldManifest: lastMpd, + newManifest: manifest + }); + } + return manifest; }; diff --git a/src/toPlaylists.js b/src/toPlaylists.js index e12732b7..57679fe5 100644 --- a/src/toPlaylists.js +++ b/src/toPlaylists.js @@ -10,6 +10,12 @@ export const generateSegments = ({ attributes, segmentInfo }) => { if (segmentInfo.template) { segmentsFn = segmentsFromTemplate; segmentAttributes = merge(attributes, segmentInfo.template); + // TODO: Decide if SegmentList be chosen ahead of SegmentBase. + // + // Judging by the specification (see the 2012 specification, section 5.3.9.2), + // SegmentBase seems to only be used as the strategy if there's no SegmentTemplate or + // SegmentList. However, the historical ordering here will use SegmentBase as the + // strategy before SegmentList. This ordering should be reconsidered. } else if (segmentInfo.base) { segmentsFn = segmentsFromBase; segmentAttributes = merge(attributes, segmentInfo.base); diff --git a/src/utils/list.js b/src/utils/list.js index d3c7d370..f236df9d 100644 --- a/src/utils/list.js +++ b/src/utils/list.js @@ -31,3 +31,37 @@ export const findIndexes = (l, key) => l.reduce((a, e, i) => { return a; }, []); + +/** + * Returns the first index that satisfies the matching function, or -1 if not found. + * + * Only necessary because of IE11 support. + * + * @param {Array} list the list to search through + * @param {Function} matchingFunction the matching function + * + * @return {number} the matching index or -1 if not found + */ +export const findIndex = (list, matchingFunction) => { + for (let i = 0; i < list.length; i++) { + if (matchingFunction(list[i])) { + return i; + } + } + + return -1; +}; + +/** + * Returns whether the list contains the search element. + * + * Only necessary because of IE11 support. + * + * @param {Array} list the list to search through + * @param {*} searchElement the element to look for + * + * @return {boolean} whether the list includes the search element or not + */ +export const includes = (list, searchElement) => { + return list.some((element) => element === searchElement); +}; diff --git a/test/index.test.js b/test/index.test.js index f3dabd1a..17aa72bc 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -18,6 +18,9 @@ import multiperiodSegmentTemplate from './manifests/multiperiod-segment-template import multiperiodSegmentList from './manifests/multiperiod-segment-list.mpd'; import multiperiodDynamic from './manifests/multiperiod-dynamic.mpd'; import audioOnly from './manifests/audio-only.mpd'; +import multiperiodStartnumber from './manifests/multiperiod-startnumber.mpd'; +import multiperiodStartnumberRemovedPeriods from + './manifests/multiperiod-startnumber-removed-periods.mpd'; import { parsedManifest as maatVttSegmentTemplateManifest } from './manifests/maat_vtt_segmentTemplate.js'; @@ -54,6 +57,12 @@ import { import { parsedManifest as locationsManifest } from './manifests/locations.js'; +import { + parsedManifest as multiperiodStartnumberManifest +} from './manifests/multiperiod-startnumber.js'; +import { + parsedManifest as multiperiodStartnumberRemovedPeriodsManifest +} from './manifests/multiperiod-startnumber-removed-periods.js'; import { parsedManifest as vttCodecsManifest @@ -129,6 +138,10 @@ QUnit.test('has parse', function(assert) { name: 'audio-only', input: audioOnly, expected: audioOnlyManifest +}, { + name: 'multiperiod_startnumber', + input: multiperiodStartnumber, + expected: multiperiodStartnumberManifest }].forEach(({ name, input, expected }) => { QUnit.test(`${name} test manifest`, function(assert) { const actual = parse(input); @@ -136,3 +149,16 @@ QUnit.test('has parse', function(assert) { assert.deepEqual(actual, expected); }); }); + +// this test is handled separately as a `lastMpd` needs to be parsed and provided +QUnit.test('multiperiod_startnumber_removed_periods test manifest', function(assert) { + const lastMpd = parse(multiperiodStartnumber); + const actual = parse(multiperiodStartnumberRemovedPeriods, { lastMpd }); + + // Since these properties are only added if `lastMpd` is provided in the call to parse, + // they should be added here, rather than in the parsed manifest file. + multiperiodStartnumberRemovedPeriodsManifest.playlistsToExclude = []; + multiperiodStartnumberRemovedPeriodsManifest.mediaGroupPlaylistsToExclude = []; + + assert.deepEqual(actual, multiperiodStartnumberRemovedPeriodsManifest); +}); diff --git a/test/manifests/608-captions.js b/test/manifests/608-captions.js index 10061783..e225cfe3 100644 --- a/test/manifests/608-captions.js +++ b/test/manifests/608-captions.js @@ -48,11 +48,13 @@ export const parsedManifest = { resolvedUri: '', targetDuration: 6, mediaSequence: 0, + discontinuitySequence: 0, segments: [ { duration: 6, timeline: 0, number: 0, + presentationTime: 0, map: { uri: '', resolvedUri: 'https://www.example.com/1080p.ts' diff --git a/test/manifests/708-captions.js b/test/manifests/708-captions.js index eae5dfd0..693340af 100644 --- a/test/manifests/708-captions.js +++ b/test/manifests/708-captions.js @@ -49,11 +49,13 @@ export const parsedManifest = { resolvedUri: '', targetDuration: 6, mediaSequence: 0, + discontinuitySequence: 0, segments: [ { duration: 6, timeline: 0, number: 0, + presentationTime: 0, map: { uri: '', resolvedUri: 'https://www.example.com/1080p.ts' diff --git a/test/manifests/audio-only.js b/test/manifests/audio-only.js index 74ecc873..c6d80963 100644 --- a/test/manifests/audio-only.js +++ b/test/manifests/audio-only.js @@ -26,7 +26,8 @@ export const parsedManifest = { resolvedUri: '', targetDuration: 60, segments: [], - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, sidx: { uri: 'http://example.com/audio_en_2c_128k_aac.mp4', resolvedUri: 'http://example.com/audio_en_2c_128k_aac.mp4', @@ -44,6 +45,7 @@ export const parsedManifest = { }, duration: 60, timeline: 0, + presentationTime: 0, number: 0 } } @@ -70,7 +72,8 @@ export const parsedManifest = { resolvedUri: '', targetDuration: 60, segments: [], - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, sidx: { uri: 'http://example.com/audio_es_2c_128k_aac.mp4', resolvedUri: 'http://example.com/audio_es_2c_128k_aac.mp4', @@ -88,6 +91,7 @@ export const parsedManifest = { }, duration: 60, timeline: 0, + presentationTime: 0, number: 0 } } diff --git a/test/manifests/location.js b/test/manifests/location.js index 5108de7e..6fb13529 100644 --- a/test/manifests/location.js +++ b/test/manifests/location.js @@ -30,11 +30,13 @@ export const parsedManifest = { resolvedUri: '', targetDuration: 6, mediaSequence: 0, + discontinuitySequence: 0, segments: [ { duration: 6, timeline: 0, number: 0, + presentationTime: 0, map: { uri: '', resolvedUri: 'https://www.example.com/1080p.ts' diff --git a/test/manifests/locations.js b/test/manifests/locations.js index d3e40760..df741de8 100644 --- a/test/manifests/locations.js +++ b/test/manifests/locations.js @@ -31,11 +31,13 @@ export const parsedManifest = { resolvedUri: '', targetDuration: 6, mediaSequence: 0, + discontinuitySequence: 0, segments: [ { duration: 6, timeline: 0, number: 0, + presentationTime: 0, map: { uri: '', resolvedUri: 'https://www.example.com/1080p.ts' diff --git a/test/manifests/maat_vtt_segmentTemplate.js b/test/manifests/maat_vtt_segmentTemplate.js index ec06727d..3c7a38ff 100644 --- a/test/manifests/maat_vtt_segmentTemplate.js +++ b/test/manifests/maat_vtt_segmentTemplate.js @@ -74,6 +74,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -146,6 +147,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -226,6 +228,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -298,6 +301,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -338,10 +342,12 @@ export const parsedManifest = { timeline: 0, resolvedUri: 'https://example.com/en.vtt', duration: 6, + presentationTime: 0, number: 0 } ], - mediaSequence: 0 + mediaSequence: 0, + discontinuitySequence: 0 } ], uri: '' @@ -368,10 +374,12 @@ export const parsedManifest = { timeline: 0, resolvedUri: 'https://example.com/es.vtt', duration: 6, + presentationTime: 0, number: 0 } ], - mediaSequence: 0 + mediaSequence: 0, + discontinuitySequence: 0 } ], uri: '' @@ -451,6 +459,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -529,6 +538,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { diff --git a/test/manifests/multiperiod-dynamic.js b/test/manifests/multiperiod-dynamic.js index 9cab1fda..df926b5f 100644 --- a/test/manifests/multiperiod-dynamic.js +++ b/test/manifests/multiperiod-dynamic.js @@ -270,6 +270,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -538,6 +539,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -824,6 +826,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -1098,6 +1101,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -1372,6 +1376,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { diff --git a/test/manifests/multiperiod-segment-list.js b/test/manifests/multiperiod-segment-list.js index f60dddde..01484f5b 100644 --- a/test/manifests/multiperiod-segment-list.js +++ b/test/manifests/multiperiod-segment-list.js @@ -24,7 +24,8 @@ export const parsedManifest = { 'SUBTITLES': 'subs' }, endList: true, - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, targetDuration: 3, resolvedUri: '', segments: [ @@ -38,7 +39,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 0, uri: 'low/segment-1.ts', - number: 1 + number: 0 }, { duration: 3, @@ -50,7 +51,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 3, uri: 'low/segment-2.ts', - number: 2 + number: 1 }, { discontinuity: true, @@ -63,7 +64,7 @@ export const parsedManifest = { timeline: 1, presentationTime: 6, uri: 'low/segment-1.ts', - number: 1 + number: 2 }, { duration: 3, @@ -75,7 +76,7 @@ export const parsedManifest = { timeline: 1, presentationTime: 9, uri: 'low/segment-2.ts', - number: 2 + number: 3 } ], timeline: 0, diff --git a/test/manifests/multiperiod-segment-template.js b/test/manifests/multiperiod-segment-template.js index f5dece29..7f847022 100644 --- a/test/manifests/multiperiod-segment-template.js +++ b/test/manifests/multiperiod-segment-template.js @@ -22,13 +22,13 @@ export const parsedManifest = { }, uri: '', endList: true, - timeline: 1, + timeline: 0, resolvedUri: '', targetDuration: 5, segments: [ { uri: 'audio/segment_0.m4f', - timeline: 1, + timeline: 0, duration: 5, resolvedUri: 'https://www.example.com/audio/segment_0.m4f', map: { @@ -40,7 +40,7 @@ export const parsedManifest = { }, { uri: 'audio/segment_1.m4f', - timeline: 1, + timeline: 0, duration: 5, resolvedUri: 'https://www.example.com/audio/segment_1.m4f', map: { @@ -52,7 +52,7 @@ export const parsedManifest = { }, { uri: 'audio/segment_2.m4f', - timeline: 1, + timeline: 0, duration: 5, resolvedUri: 'https://www.example.com/audio/segment_2.m4f', map: { @@ -65,42 +65,43 @@ export const parsedManifest = { { discontinuity: true, uri: 'audio/segment_0.m4f', - timeline: 2, + timeline: 1, duration: 5, resolvedUri: 'https://www.example.com/audio/segment_0.m4f', map: { uri: 'audio/init.m4f', resolvedUri: 'https://www.example.com/audio/init.m4f' }, - number: 0, + number: 3, presentationTime: 15 }, { uri: 'audio/segment_1.m4f', - timeline: 2, + timeline: 1, duration: 5, resolvedUri: 'https://www.example.com/audio/segment_1.m4f', map: { uri: 'audio/init.m4f', resolvedUri: 'https://www.example.com/audio/init.m4f' }, - number: 1, + number: 4, presentationTime: 20 }, { uri: 'audio/segment_2.m4f', - timeline: 2, + timeline: 1, duration: 5, resolvedUri: 'https://www.example.com/audio/segment_2.m4f', map: { uri: 'audio/init.m4f', resolvedUri: 'https://www.example.com/audio/init.m4f' }, - number: 2, + number: 5, presentationTime: 25 } ], - mediaSequence: 0 + mediaSequence: 0, + discontinuitySequence: 0 } ], uri: '' @@ -127,13 +128,13 @@ export const parsedManifest = { }, uri: '', endList: true, - timeline: 1, + timeline: 0, resolvedUri: '', targetDuration: 5, segments: [ { uri: 'video/segment_0.m4f', - timeline: 1, + timeline: 0, duration: 5, resolvedUri: 'https://www.example.com/video/segment_0.m4f', map: { @@ -145,7 +146,7 @@ export const parsedManifest = { }, { uri: 'video/segment_1.m4f', - timeline: 1, + timeline: 0, duration: 5, resolvedUri: 'https://www.example.com/video/segment_1.m4f', map: { @@ -157,7 +158,7 @@ export const parsedManifest = { }, { uri: 'video/segment_2.m4f', - timeline: 1, + timeline: 0, duration: 5, resolvedUri: 'https://www.example.com/video/segment_2.m4f', map: { @@ -170,42 +171,43 @@ export const parsedManifest = { { discontinuity: true, uri: 'video/segment_0.m4f', - timeline: 2, + timeline: 1, duration: 5, resolvedUri: 'https://www.example.com/video/segment_0.m4f', map: { uri: 'video/init.m4f', resolvedUri: 'https://www.example.com/video/init.m4f' }, - number: 0, + number: 3, presentationTime: 15 }, { uri: 'video/segment_1.m4f', - timeline: 2, + timeline: 1, duration: 5, resolvedUri: 'https://www.example.com/video/segment_1.m4f', map: { uri: 'video/init.m4f', resolvedUri: 'https://www.example.com/video/init.m4f' }, - number: 1, + number: 4, presentationTime: 20 }, { uri: 'video/segment_2.m4f', - timeline: 2, + timeline: 1, duration: 5, resolvedUri: 'https://www.example.com/video/segment_2.m4f', map: { uri: 'video/init.m4f', resolvedUri: 'https://www.example.com/video/init.m4f' }, - number: 2, + number: 5, presentationTime: 25 } ], - mediaSequence: 0 + mediaSequence: 0, + discontinuitySequence: 0 } ] }; diff --git a/test/manifests/multiperiod-startnumber-removed-periods.js b/test/manifests/multiperiod-startnumber-removed-periods.js new file mode 100644 index 00000000..afd11fed --- /dev/null +++ b/test/manifests/multiperiod-startnumber-removed-periods.js @@ -0,0 +1,450 @@ +export const parsedManifest = { + allowCache: true, + discontinuityStarts: [], + duration: 0, + endList: true, + mediaGroups: { + 'AUDIO': { + audio: { + 'en (main)': { + autoselect: true, + default: true, + language: 'en', + playlists: [ + { + attributes: { + 'BANDWIDTH': 129262, + 'CODECS': 'mp4a.40.5', + 'NAME': 'v0', + 'PROGRAM-ID': 1 + }, + endList: false, + mediaSequence: 7, + discontinuitySequence: 2, + discontinuityStarts: [0], + resolvedUri: '', + segments: [ + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/audio/v0/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/audio/v0/862.m4f', + timeline: 3, + uri: '862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/audio/v0/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/audio/v0/863.m4f', + timeline: 3, + uri: '863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/audio/v0/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/audio/v0/864.m4f', + timeline: 3, + uri: '864.m4f' + } + ], + targetDuration: 1, + timeline: 3, + uri: '' + } + ], + uri: '' + } + } + }, + 'CLOSED-CAPTIONS': {}, + 'SUBTITLES': {}, + 'VIDEO': {} + }, + minimumUpdatePeriod: 2000, + playlists: [ + { + attributes: { + 'AUDIO': 'audio', + 'BANDWIDTH': 2942295, + 'CODECS': 'avc1.4d001f', + 'NAME': 'D', + 'PROGRAM-ID': 1, + 'RESOLUTION': { + height: 720, + width: 1280 + }, + 'SUBTITLES': 'subs' + }, + endList: false, + mediaSequence: 7, + discontinuitySequence: 2, + discontinuityStarts: [0], + resolvedUri: '', + segments: [ + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/video/D/D862.m4f', + timeline: 3, + uri: 'D862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/video/D/D863.m4f', + timeline: 3, + uri: 'D863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/video/D/D864.m4f', + timeline: 3, + uri: 'D864.m4f' + } + ], + targetDuration: 1, + timeline: 3, + uri: '' + }, + { + attributes: { + 'AUDIO': 'audio', + 'BANDWIDTH': 4267536, + 'CODECS': 'avc1.640020', + 'NAME': 'E', + 'PROGRAM-ID': 1, + 'RESOLUTION': { + height: 720, + width: 1280 + }, + 'SUBTITLES': 'subs' + }, + endList: false, + mediaSequence: 7, + discontinuitySequence: 2, + discontinuityStarts: [0], + resolvedUri: '', + segments: [ + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/video/E/E862.m4f', + timeline: 3, + uri: 'E862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/video/E/E863.m4f', + timeline: 3, + uri: 'E863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/video/E/E864.m4f', + timeline: 3, + uri: 'E864.m4f' + } + ], + targetDuration: 1, + timeline: 3, + uri: '' + }, + { + attributes: { + 'AUDIO': 'audio', + 'BANDWIDTH': 5256859, + 'CODECS': 'avc1.640020', + 'NAME': 'F', + 'PROGRAM-ID': 1, + 'RESOLUTION': { + height: 720, + width: 1280 + }, + 'SUBTITLES': 'subs' + }, + endList: false, + mediaSequence: 7, + discontinuitySequence: 2, + discontinuityStarts: [0], + resolvedUri: '', + segments: [ + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/F/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/video/F/F862.m4f', + timeline: 3, + uri: 'F862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/F/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/video/F/F863.m4f', + timeline: 3, + uri: 'F863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/F/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/video/F/F864.m4f', + timeline: 3, + uri: 'F864.m4f' + } + ], + targetDuration: 1, + timeline: 3, + uri: '' + }, + { + attributes: { + 'AUDIO': 'audio', + 'BANDWIDTH': 240781, + 'CODECS': 'avc1.4d000d', + 'NAME': 'A', + 'PROGRAM-ID': 1, + 'RESOLUTION': { + height: 234, + width: 416 + }, + 'SUBTITLES': 'subs' + }, + endList: false, + mediaSequence: 7, + discontinuitySequence: 2, + discontinuityStarts: [0], + resolvedUri: '', + segments: [ + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/video/A/A862.m4f', + timeline: 3, + uri: 'A862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/video/A/A863.m4f', + timeline: 3, + uri: 'A863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/video/A/A864.m4f', + timeline: 3, + uri: 'A864.m4f' + } + ], + targetDuration: 1, + timeline: 3, + uri: '' + }, + { + attributes: { + 'AUDIO': 'audio', + 'BANDWIDTH': 494354, + 'CODECS': 'avc1.4d001e', + 'NAME': 'B', + 'PROGRAM-ID': 1, + 'RESOLUTION': { + height: 360, + width: 640 + }, + 'SUBTITLES': 'subs' + }, + endList: false, + mediaSequence: 7, + discontinuitySequence: 2, + discontinuityStarts: [0], + resolvedUri: '', + segments: [ + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/video/B/B862.m4f', + timeline: 3, + uri: 'B862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/video/B/B863.m4f', + timeline: 3, + uri: 'B863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/video/B/B864.m4f', + timeline: 3, + uri: 'B864.m4f' + } + ], + targetDuration: 1, + timeline: 3, + uri: '' + }, + { + attributes: { + 'AUDIO': 'audio', + 'BANDWIDTH': 1277155, + 'CODECS': 'avc1.4d001f', + 'NAME': 'C', + 'PROGRAM-ID': 1, + 'RESOLUTION': { + height: 540, + width: 960 + }, + 'SUBTITLES': 'subs' + }, + endList: false, + mediaSequence: 7, + discontinuitySequence: 2, + discontinuityStarts: [0], + resolvedUri: '', + segments: [ + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/C/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/video/C/C862.m4f', + timeline: 3, + uri: 'C862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/C/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/video/C/C863.m4f', + timeline: 3, + uri: 'C863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/C/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/video/C/C864.m4f', + timeline: 3, + uri: 'C864.m4f' + } + ], + targetDuration: 1, + timeline: 3, + uri: '' + } + ], + segments: [], + suggestedPresentationDelay: 6, + uri: '' +}; diff --git a/test/manifests/multiperiod-startnumber-removed-periods.mpd b/test/manifests/multiperiod-startnumber-removed-periods.mpd new file mode 100644 index 00000000..df12a207 --- /dev/null +++ b/test/manifests/multiperiod-startnumber-removed-periods.mpd @@ -0,0 +1,185 @@ + + + + + + + + http://example.com/audio/v0/ + + + + + + + + + + + http://example.com/video/D/ + + + + + + + + http://example.com/video/E/ + + + + + + + + http://example.com/video/F/ + + + + + + + + + + http://example.com/video/A/ + + + + + + + + http://example.com/video/B/ + + + + + + + + http://example.com/video/C/ + + + + + + + + + + diff --git a/test/manifests/multiperiod-startnumber.js b/test/manifests/multiperiod-startnumber.js new file mode 100644 index 00000000..2a93d0a6 --- /dev/null +++ b/test/manifests/multiperiod-startnumber.js @@ -0,0 +1,1045 @@ +export const parsedManifest = { + allowCache: true, + discontinuityStarts: [], + duration: 0, + endList: true, + mediaGroups: { + 'AUDIO': { + audio: { + 'en (main)': { + autoselect: true, + default: true, + language: 'en', + playlists: [ + { + attributes: { + 'BANDWIDTH': 129262, + 'CODECS': 'mp4a.40.5', + 'NAME': 'v0', + 'PROGRAM-ID': 1 + }, + endList: false, + mediaSequence: 0, + discontinuitySequence: 0, + resolvedUri: '', + segments: [ + { + duration: 1, + map: { + resolvedUri: 'http://example.com/audio/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 100, + number: 0, + resolvedUri: 'http://example.com/audio/500.m4f', + timeline: 0, + uri: '500.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/audio/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 101, + number: 1, + resolvedUri: 'http://example.com/audio/501.m4f', + timeline: 0, + uri: '501.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/audio/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 102, + number: 2, + resolvedUri: 'http://example.com/audio/502.m4f', + timeline: 0, + uri: '502.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/audio/v0/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 103, + number: 3, + resolvedUri: 'http://example.com/audio/v0/000.m4f', + timeline: 1, + uri: '000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/audio/v0/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 105, + number: 4, + resolvedUri: 'http://example.com/audio/v0/001.m4f', + timeline: 1, + uri: '001.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/audio/v0/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 107, + number: 5, + resolvedUri: 'http://example.com/audio/v0/000.m4f', + timeline: 2, + uri: '000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/audio/v0/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 109, + number: 6, + resolvedUri: 'http://example.com/audio/v0/001.m4f', + timeline: 2, + uri: '001.m4f' + }, + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/audio/v0/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/audio/v0/862.m4f', + timeline: 3, + uri: '862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/audio/v0/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/audio/v0/863.m4f', + timeline: 3, + uri: '863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/audio/v0/init.mp4', + uri: 'init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/audio/v0/864.m4f', + timeline: 3, + uri: '864.m4f' + } + ], + targetDuration: 1, + timeline: 0, + uri: '' + } + ], + uri: '' + } + } + }, + 'CLOSED-CAPTIONS': {}, + 'SUBTITLES': {}, + 'VIDEO': {} + }, + minimumUpdatePeriod: 2000, + playlists: [ + { + attributes: { + 'AUDIO': 'audio', + 'BANDWIDTH': 2942295, + 'CODECS': 'avc1.4d001f', + 'NAME': 'D', + 'PROGRAM-ID': 1, + 'RESOLUTION': { + height: 720, + width: 1280 + }, + 'SUBTITLES': 'subs' + }, + endList: false, + mediaSequence: 0, + discontinuitySequence: 0, + resolvedUri: '', + segments: [ + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 100, + number: 0, + resolvedUri: 'http://example.com/video/D/D500.m4f', + timeline: 0, + uri: 'D500.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 101, + number: 1, + resolvedUri: 'http://example.com/video/D/D501.m4f', + timeline: 0, + uri: 'D501.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 102, + number: 2, + resolvedUri: 'http://example.com/video/D/D502.m4f', + timeline: 0, + uri: 'D502.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 103, + number: 3, + resolvedUri: 'http://example.com/video/D/D000.m4f', + timeline: 1, + uri: 'D000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 105, + number: 4, + resolvedUri: 'http://example.com/video/D/D001.m4f', + timeline: 1, + uri: 'D001.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 107, + number: 5, + resolvedUri: 'http://example.com/video/D/D000.m4f', + timeline: 2, + uri: 'D000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 109, + number: 6, + resolvedUri: 'http://example.com/video/D/D001.m4f', + timeline: 2, + uri: 'D001.m4f' + }, + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/video/D/D862.m4f', + timeline: 3, + uri: 'D862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/video/D/D863.m4f', + timeline: 3, + uri: 'D863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/D/D_init.mp4', + uri: 'D_init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/video/D/D864.m4f', + timeline: 3, + uri: 'D864.m4f' + } + ], + targetDuration: 1, + timeline: 0, + uri: '' + }, + { + attributes: { + 'AUDIO': 'audio', + 'BANDWIDTH': 4267536, + 'CODECS': 'avc1.640020', + 'NAME': 'E', + 'PROGRAM-ID': 1, + 'RESOLUTION': { + height: 720, + width: 1280 + }, + 'SUBTITLES': 'subs' + }, + endList: false, + mediaSequence: 0, + discontinuitySequence: 0, + resolvedUri: '', + segments: [ + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 100, + number: 0, + resolvedUri: 'http://example.com/video/E/E500.m4f', + timeline: 0, + uri: 'E500.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 101, + number: 1, + resolvedUri: 'http://example.com/video/E/E501.m4f', + timeline: 0, + uri: 'E501.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 102, + number: 2, + resolvedUri: 'http://example.com/video/E/E502.m4f', + timeline: 0, + uri: 'E502.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 103, + number: 3, + resolvedUri: 'http://example.com/video/E/E000.m4f', + timeline: 1, + uri: 'E000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 105, + number: 4, + resolvedUri: 'http://example.com/video/E/E001.m4f', + timeline: 1, + uri: 'E001.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 107, + number: 5, + resolvedUri: 'http://example.com/video/E/E000.m4f', + timeline: 2, + uri: 'E000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 109, + number: 6, + resolvedUri: 'http://example.com/video/E/E001.m4f', + timeline: 2, + uri: 'E001.m4f' + }, + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/video/E/E862.m4f', + timeline: 3, + uri: 'E862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/video/E/E863.m4f', + timeline: 3, + uri: 'E863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/E_init.mp4', + uri: 'E_init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/video/E/E864.m4f', + timeline: 3, + uri: 'E864.m4f' + } + ], + targetDuration: 1, + timeline: 0, + uri: '' + }, + { + attributes: { + 'AUDIO': 'audio', + 'BANDWIDTH': 5256859, + 'CODECS': 'avc1.640020', + 'NAME': 'F', + 'PROGRAM-ID': 1, + 'RESOLUTION': { + height: 720, + width: 1280 + }, + 'SUBTITLES': 'subs' + }, + endList: false, + mediaSequence: 0, + discontinuitySequence: 0, + resolvedUri: '', + segments: [ + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 100, + number: 0, + resolvedUri: 'http://example.com/video/E/F500.m4f', + timeline: 0, + uri: 'F500.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 101, + number: 1, + resolvedUri: 'http://example.com/video/E/F501.m4f', + timeline: 0, + uri: 'F501.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 102, + number: 2, + resolvedUri: 'http://example.com/video/E/F502.m4f', + timeline: 0, + uri: 'F502.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/video/F/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 103, + number: 3, + resolvedUri: 'http://example.com/video/F/F000.m4f', + timeline: 1, + uri: 'F000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/video/F/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 105, + number: 4, + resolvedUri: 'http://example.com/video/F/F001.m4f', + timeline: 1, + uri: 'F001.m4f' + }, + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/F/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 107, + number: 5, + resolvedUri: 'http://example.com/video/F/F000.m4f', + timeline: 2, + uri: 'F000.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/F/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 108, + number: 6, + resolvedUri: 'http://example.com/video/F/F001.m4f', + timeline: 2, + uri: 'F001.m4f' + }, + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/F/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/video/F/F862.m4f', + timeline: 3, + uri: 'F862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/F/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/video/F/F863.m4f', + timeline: 3, + uri: 'F863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/F/F_init.mp4', + uri: 'F_init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/video/F/F864.m4f', + timeline: 3, + uri: 'F864.m4f' + } + ], + targetDuration: 1, + timeline: 0, + uri: '' + }, + { + attributes: { + 'AUDIO': 'audio', + 'BANDWIDTH': 240781, + 'CODECS': 'avc1.4d000d', + 'NAME': 'A', + 'PROGRAM-ID': 1, + 'RESOLUTION': { + height: 234, + width: 416 + }, + 'SUBTITLES': 'subs' + }, + endList: false, + mediaSequence: 0, + discontinuitySequence: 0, + resolvedUri: '', + segments: [ + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 100, + number: 0, + resolvedUri: 'http://example.com/video/A/A500.m4f', + timeline: 0, + uri: 'A500.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 101, + number: 1, + resolvedUri: 'http://example.com/video/A/A501.m4f', + timeline: 0, + uri: 'A501.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 102, + number: 2, + resolvedUri: 'http://example.com/video/A/A502.m4f', + timeline: 0, + uri: 'A502.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 103, + number: 3, + resolvedUri: 'http://example.com/video/A/A000.m4f', + timeline: 1, + uri: 'A000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 105, + number: 4, + resolvedUri: 'http://example.com/video/A/A001.m4f', + timeline: 1, + uri: 'A001.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 107, + number: 5, + resolvedUri: 'http://example.com/video/A/A000.m4f', + timeline: 2, + uri: 'A000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 109, + number: 6, + resolvedUri: 'http://example.com/video/A/A001.m4f', + timeline: 2, + uri: 'A001.m4f' + }, + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/video/A/A862.m4f', + timeline: 3, + uri: 'A862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/video/A/A863.m4f', + timeline: 3, + uri: 'A863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/A/A_init.mp4', + uri: 'A_init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/video/A/A864.m4f', + timeline: 3, + uri: 'A864.m4f' + } + ], + targetDuration: 1, + timeline: 0, + uri: '' + }, + { + attributes: { + 'AUDIO': 'audio', + 'BANDWIDTH': 494354, + 'CODECS': 'avc1.4d001e', + 'NAME': 'B', + 'PROGRAM-ID': 1, + 'RESOLUTION': { + height: 360, + width: 640 + }, + 'SUBTITLES': 'subs' + }, + endList: false, + mediaSequence: 0, + discontinuitySequence: 0, + resolvedUri: '', + segments: [ + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 100, + number: 0, + resolvedUri: 'http://example.com/video/B/B500.m4f', + timeline: 0, + uri: 'B500.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 101, + number: 1, + resolvedUri: 'http://example.com/video/B/B501.m4f', + timeline: 0, + uri: 'B501.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 102, + number: 2, + resolvedUri: 'http://example.com/video/B/B502.m4f', + timeline: 0, + uri: 'B502.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 103, + number: 3, + resolvedUri: 'http://example.com/video/B/B000.m4f', + timeline: 1, + uri: 'B000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 105, + number: 4, + resolvedUri: 'http://example.com/video/B/B001.m4f', + timeline: 1, + uri: 'B001.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 107, + number: 5, + resolvedUri: 'http://example.com/video/B/B000.m4f', + timeline: 2, + uri: 'B000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 109, + number: 6, + resolvedUri: 'http://example.com/video/B/B001.m4f', + timeline: 2, + uri: 'B001.m4f' + }, + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/video/B/B862.m4f', + timeline: 3, + uri: 'B862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/video/B/B863.m4f', + timeline: 3, + uri: 'B863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/B/B_init.mp4', + uri: 'B_init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/video/B/B864.m4f', + timeline: 3, + uri: 'B864.m4f' + } + ], + targetDuration: 1, + timeline: 0, + uri: '' + }, + { + attributes: { + 'AUDIO': 'audio', + 'BANDWIDTH': 1277155, + 'CODECS': 'avc1.4d001e', + 'NAME': 'C', + 'PROGRAM-ID': 1, + 'RESOLUTION': { + height: 540, + width: 960 + }, + 'SUBTITLES': 'subs' + }, + endList: false, + mediaSequence: 0, + discontinuitySequence: 0, + resolvedUri: '', + segments: [ + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 100, + number: 0, + resolvedUri: 'http://example.com/video/E/C500.m4f', + timeline: 0, + uri: 'C500.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 101, + number: 1, + resolvedUri: 'http://example.com/video/E/C501.m4f', + timeline: 0, + uri: 'C501.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/E/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 102, + number: 2, + resolvedUri: 'http://example.com/video/E/C502.m4f', + timeline: 0, + uri: 'C502.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/video/C/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 103, + number: 3, + resolvedUri: 'http://example.com/video/C/C000.m4f', + timeline: 1, + uri: 'C000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/video/C/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 105, + number: 4, + resolvedUri: 'http://example.com/video/C/C001.m4f', + timeline: 1, + uri: 'C001.m4f' + }, + { + discontinuity: true, + duration: 2, + map: { + resolvedUri: 'http://example.com/video/C/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 107, + number: 5, + resolvedUri: 'http://example.com/video/C/C000.m4f', + timeline: 2, + uri: 'C000.m4f' + }, + { + duration: 2, + map: { + resolvedUri: 'http://example.com/video/C/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 109, + number: 6, + resolvedUri: 'http://example.com/video/C/C001.m4f', + timeline: 2, + uri: 'C001.m4f' + }, + { + discontinuity: true, + duration: 1, + map: { + resolvedUri: 'http://example.com/video/C/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 111, + number: 7, + resolvedUri: 'http://example.com/video/C/C862.m4f', + timeline: 3, + uri: 'C862.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/C/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 112, + number: 8, + resolvedUri: 'http://example.com/video/C/C863.m4f', + timeline: 3, + uri: 'C863.m4f' + }, + { + duration: 1, + map: { + resolvedUri: 'http://example.com/video/C/C_init.mp4', + uri: 'C_init.mp4' + }, + presentationTime: 113, + number: 9, + resolvedUri: 'http://example.com/video/C/C864.m4f', + timeline: 3, + uri: 'C864.m4f' + } + ], + targetDuration: 1, + timeline: 0, + uri: '' + } + ], + segments: [], + suggestedPresentationDelay: 6, + uri: '' +}; diff --git a/test/manifests/multiperiod-startnumber.mpd b/test/manifests/multiperiod-startnumber.mpd new file mode 100644 index 00000000..6fb927f0 --- /dev/null +++ b/test/manifests/multiperiod-startnumber.mpd @@ -0,0 +1,647 @@ + + + + + + + + http://example.com/audio/1 + + + + + + + + + + http://example.com/video/D/ + + + + + + + + http://example.com/video/E/ + + + + + + + + http://example.com/video/E/ + + + + + + + + + + http://example.com/video/A/ + + + + + + + + http://example.com/video/B/ + + + + + + + + http://example.com/video/E/ + + + + + + + + + + + + + + http://example.com/audio/v0/ + + + + + + + + + + http://example.com/video/D/ + + + + + + + + http://example.com/video/E/ + + + + + + + + http://example.com/video/F/ + + + + + + + + http://example.com/video/A/ + + + + + + + + http://example.com/video/B/ + + + + + + + + http://example.com/video/C/ + + + + + + + + + + + + + + http://example.com/audio/v0/ + + + + + + + + + + http://example.com/video/D/ + + + + + + + + http://example.com/video/E/ + + + + + + + + http://example.com/video/F/ + + + + + + + + http://example.com/video/A/ + + + + + + + + http://example.com/video/B/ + + + + + + + + http://example.com/video/C/ + + + + + + + + + + + + + + http://example.com/audio/v0/ + + + + + + + + + + + http://example.com/video/D/ + + + + + + + + http://example.com/video/E/ + + + + + + + + http://example.com/video/F/ + + + + + + + + + + http://example.com/video/A/ + + + + + + + + http://example.com/video/B/ + + + + + + + + http://example.com/video/C/ + + + + + + + + + + diff --git a/test/manifests/multiperiod.js b/test/manifests/multiperiod.js index f61feac7..3788ce72 100644 --- a/test/manifests/multiperiod.js +++ b/test/manifests/multiperiod.js @@ -270,6 +270,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -538,6 +539,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -824,6 +826,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -1098,6 +1101,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -1372,6 +1376,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { diff --git a/test/manifests/segmentBase.js b/test/manifests/segmentBase.js index 1fb5d5d0..86a38e1d 100644 --- a/test/manifests/segmentBase.js +++ b/test/manifests/segmentBase.js @@ -27,11 +27,13 @@ export const parsedManifest = { resolvedUri: '', targetDuration: 6, mediaSequence: 0, + discontinuitySequence: 0, segments: [ { duration: 6, timeline: 0, number: 0, + presentationTime: 0, map: { uri: '', resolvedUri: 'https://www.example.com/1080p.ts' diff --git a/test/manifests/segmentList.js b/test/manifests/segmentList.js index 85f67552..ec9cab80 100644 --- a/test/manifests/segmentList.js +++ b/test/manifests/segmentList.js @@ -24,7 +24,8 @@ export const parsedManifest = { 'SUBTITLES': 'subs' }, endList: true, - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, targetDuration: 1, resolvedUri: '', segments: [ @@ -38,7 +39,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 0, uri: 'low/segment-1.ts', - number: 1 + number: 0 }, { duration: 1, @@ -50,7 +51,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 1, uri: 'low/segment-2.ts', - number: 2 + number: 1 }, { duration: 1, @@ -62,7 +63,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 2, uri: 'low/segment-3.ts', - number: 3 + number: 2 }, { duration: 1, @@ -74,7 +75,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 3, uri: 'low/segment-4.ts', - number: 4 + number: 3 }, { duration: 1, @@ -86,7 +87,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 4, uri: 'low/segment-5.ts', - number: 5 + number: 4 }, { duration: 1, @@ -98,7 +99,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 5, uri: 'low/segment-6.ts', - number: 6 + number: 5 } ], timeline: 0, @@ -119,7 +120,8 @@ export const parsedManifest = { }, endList: true, resolvedUri: '', - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, targetDuration: 60, segments: [ { @@ -132,7 +134,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 0, uri: 'high/segment-1.ts', - number: 1 + number: 0 }, { duration: 60, @@ -144,7 +146,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 60, uri: 'high/segment-2.ts', - number: 2 + number: 1 }, { duration: 60, @@ -156,7 +158,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 120, uri: 'high/segment-3.ts', - number: 3 + number: 2 }, { duration: 60, @@ -168,7 +170,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 180, uri: 'high/segment-4.ts', - number: 4 + number: 3 }, { duration: 60, @@ -180,7 +182,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 240, uri: 'high/segment-5.ts', - number: 5 + number: 4 }, { duration: 60, @@ -192,7 +194,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 300, uri: 'high/segment-6.ts', - number: 6 + number: 5 }, { duration: 60, @@ -204,7 +206,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 360, uri: 'high/segment-7.ts', - number: 7 + number: 6 }, { duration: 60, @@ -216,7 +218,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 420, uri: 'high/segment-8.ts', - number: 8 + number: 7 }, { duration: 60, @@ -228,7 +230,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 480, uri: 'high/segment-9.ts', - number: 9 + number: 8 }, { duration: 60, @@ -240,7 +242,7 @@ export const parsedManifest = { timeline: 0, presentationTime: 540, uri: 'high/segment-10.ts', - number: 10 + number: 9 } ], timeline: 0, diff --git a/test/manifests/vtt_codecs.js b/test/manifests/vtt_codecs.js index 937439aa..22864c07 100644 --- a/test/manifests/vtt_codecs.js +++ b/test/manifests/vtt_codecs.js @@ -74,6 +74,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -146,6 +147,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -226,6 +228,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -298,6 +301,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -339,10 +343,12 @@ export const parsedManifest = { timeline: 0, resolvedUri: 'https://example.com/en.dash', duration: 6, + presentationTime: 0, number: 0 } ], - mediaSequence: 0 + mediaSequence: 0, + discontinuitySequence: 0 } ], uri: '' @@ -369,10 +375,12 @@ export const parsedManifest = { timeline: 0, resolvedUri: 'https://example.com/es.vtt', duration: 6, + presentationTime: 0, number: 0 } ], - mediaSequence: 0 + mediaSequence: 0, + discontinuitySequence: 0 } ], uri: '' @@ -452,6 +460,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { @@ -530,6 +539,7 @@ export const parsedManifest = { } ], mediaSequence: 0, + discontinuitySequence: 0, contentProtection: { 'com.widevine.alpha': { attributes: { diff --git a/test/manifests/webmsegments.js b/test/manifests/webmsegments.js index bada5915..70728332 100644 --- a/test/manifests/webmsegments.js +++ b/test/manifests/webmsegments.js @@ -20,13 +20,13 @@ export const parsedManifest = { }, uri: '', endList: true, - timeline: 1, + timeline: 0, resolvedUri: '', targetDuration: 4, segments: [ { uri: 'audio/segment_0.chk', - timeline: 1, + timeline: 0, duration: 4, resolvedUri: 'https://www.example.com/audio/segment_0.chk', map: { @@ -38,7 +38,7 @@ export const parsedManifest = { }, { uri: 'audio/segment_1.chk', - timeline: 1, + timeline: 0, duration: 4, resolvedUri: 'https://www.example.com/audio/segment_1.chk', map: { @@ -50,7 +50,7 @@ export const parsedManifest = { }, { uri: 'audio/segment_2.chk', - timeline: 1, + timeline: 0, duration: 4, resolvedUri: 'https://www.example.com/audio/segment_2.chk', map: { @@ -62,7 +62,7 @@ export const parsedManifest = { }, { uri: 'audio/segment_3.chk', - timeline: 1, + timeline: 0, duration: 4, resolvedUri: 'https://www.example.com/audio/segment_3.chk', map: { @@ -73,7 +73,8 @@ export const parsedManifest = { presentationTime: 12 } ], - mediaSequence: 0 + mediaSequence: 0, + discontinuitySequence: 0 } ], uri: '' @@ -102,13 +103,13 @@ export const parsedManifest = { }, uri: '', endList: true, - timeline: 1, + timeline: 0, resolvedUri: '', targetDuration: 4, segments: [ { uri: 'video/segment_0.chk', - timeline: 1, + timeline: 0, duration: 4, resolvedUri: 'https://www.example.com/video/segment_0.chk', map: { @@ -120,7 +121,7 @@ export const parsedManifest = { }, { uri: 'video/segment_1.chk', - timeline: 1, + timeline: 0, duration: 4, resolvedUri: 'https://www.example.com/video/segment_1.chk', map: { @@ -132,7 +133,7 @@ export const parsedManifest = { }, { uri: 'video/segment_2.chk', - timeline: 1, + timeline: 0, duration: 4, resolvedUri: 'https://www.example.com/video/segment_2.chk', map: { @@ -144,7 +145,7 @@ export const parsedManifest = { }, { uri: 'video/segment_3.chk', - timeline: 1, + timeline: 0, duration: 4, resolvedUri: 'https://www.example.com/video/segment_3.chk', map: { @@ -155,7 +156,8 @@ export const parsedManifest = { presentationTime: 12 } ], - mediaSequence: 0 + mediaSequence: 0, + discontinuitySequence: 0 } ] }; diff --git a/test/playlist-merge.test.js b/test/playlist-merge.test.js new file mode 100644 index 00000000..6abfee28 --- /dev/null +++ b/test/playlist-merge.test.js @@ -0,0 +1,1938 @@ +import { + findPlaylistWithName, + findMediaGroupPlaylistWithName, + getRemovedPlaylists, + getRemovedMediaGroupPlaylists, + getIncompletePlaylists, + getMediaGroupPlaylists, + getMediaGroupPlaylistIdentificationObjects, + repositionSegmentsOnTimeline, + positionPlaylistOnTimeline, + positionPlaylistsOnTimeline, + positionMediaGroupPlaylistsOnTimeline, + removeMediaGroupPlaylists, + positionManifestOnTimeline +} from '../src/playlist-merge'; +import QUnit from 'qunit'; + +QUnit.module('findPlaylistWithName'); + +QUnit.test('returns nothing when no playlists', function(assert) { + assert.notOk(findPlaylistWithName([], 'A'), 'nothing when no playlists'); +}); + +QUnit.test('returns nothing when no match', function(assert) { + const playlists = [ + { attributes: { NAME: 'B' } } + ]; + + assert.notOk(findPlaylistWithName(playlists, 'A'), 'nothing when no match'); +}); + +QUnit.test('returns matching playlist', function(assert) { + const playlists = [ + { attributes: { NAME: 'A' } }, + { attributes: { NAME: 'B' } }, + { attributes: { NAME: 'C' } } + ]; + + assert.deepEqual( + findPlaylistWithName(playlists, 'B'), + playlists[1], + 'returns matching playlist' + ); +}); + +QUnit.module('findMediaGroupPlaylistWithName'); + +QUnit.test('returns nothing when no media group playlists', function(assert) { + const manifest = { mediaGroups: { AUDIO: {} } }; + + assert.notOk( + findMediaGroupPlaylistWithName({ + playlistName: 'A', + type: 'AUDIO', + group: 'audio', + label: 'en', + manifest + }), + 'returns nothing when no media group playlists' + ); +}); + +QUnit.test('returns nothing when no match', function(assert) { + const manifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [ { attributes: { NAME: 'B' } } ] + } + } + } + } + }; + + assert.notOk( + findMediaGroupPlaylistWithName({ + playlistName: 'A', + type: 'AUDIO', + group: 'audio', + label: 'en', + manifest + }), + 'returns nothing when no media group playlists' + ); +}); + +QUnit.test('returns matching playlist', function(assert) { + const playlistB = { attributes: { NAME: 'B' } }; + const manifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [ + { attributes: { NAME: 'A' } }, + { attributes: { NAME: 'B' } }, + { attributes: { NAME: 'C' } } + ] + }, + fr: { + playlists: [ + { attributes: { NAME: 'A' } }, + playlistB, + { attributes: { NAME: 'C' } } + ] + } + } + } + } + }; + + assert.deepEqual( + findMediaGroupPlaylistWithName({ + playlistName: 'B', + type: 'AUDIO', + group: 'audio', + label: 'fr', + manifest + }), + playlistB, + 'returns matching playlist' + ); +}); + +QUnit.module('getRemovedPlaylists'); + +QUnit.test('returns nothing when no old playlists', function(assert) { + const newPlaylistA = { attributes: { NAME: 'A' } }; + const newPlaylistB = { attributes: { NAME: 'B' } }; + const newPlaylists = [newPlaylistA, newPlaylistB]; + + assert.deepEqual( + getRemovedPlaylists({ oldPlaylists: [], newPlaylists }), + [], + 'nothing when no old playlists' + ); +}); + +QUnit.test('returns nothing when all playlists are available', function(assert) { + const oldPlaylistA = { attributes: { NAME: 'A' } }; + const oldPlaylistB = { attributes: { NAME: 'B' } }; + const newPlaylistA = { attributes: { NAME: 'A' } }; + const newPlaylistB = { attributes: { NAME: 'B' } }; + const oldPlaylists = [oldPlaylistA, oldPlaylistB]; + const newPlaylists = [newPlaylistA, newPlaylistB]; + + assert.deepEqual( + getRemovedPlaylists({ oldPlaylists, newPlaylists }), + [], + 'nothing when all playlists are available' + ); +}); + +QUnit.test('returns old playlists not available in new playlists', function(assert) { + const oldPlaylistA = { attributes: { NAME: 'A' } }; + const oldPlaylistB = { attributes: { NAME: 'B' } }; + const oldPlaylistC = { attributes: { NAME: 'C' } }; + const oldPlaylistD = { attributes: { NAME: 'D' } }; + const newPlaylistA = { attributes: { NAME: 'A' } }; + const newPlaylistC = { attributes: { NAME: 'C' } }; + const oldPlaylists = [oldPlaylistA, oldPlaylistB, oldPlaylistC, oldPlaylistD]; + const newPlaylists = [newPlaylistA, newPlaylistC]; + + assert.deepEqual( + getRemovedPlaylists({ oldPlaylists, newPlaylists }), + [oldPlaylistB, oldPlaylistD], + 'returned old playlists not available in new playlists' + ); +}); + +QUnit.module('getRemovedMediaGroupPlaylists'); + +QUnit.test('returns nothing when no old media group playlists', function(assert) { + const playlistA = { attributes: { NAME: 'A' } }; + const playlistB = { attributes: { NAME: 'B' } }; + const oldManifest = { mediaGroups: { AUDIO: {} } }; + const newManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [playlistA] + }, + fr: { + playlists: [playlistB] + } + } + } + } + }; + + assert.deepEqual( + getRemovedMediaGroupPlaylists({ oldManifest, newManifest }), + [], + 'nothing when no old playlists' + ); +}); + +QUnit.test('returns nothing when all media group playlists are available', function(assert) { + const oldPlaylistA = { attributes: { NAME: 'A' } }; + const oldPlaylistB = { attributes: { NAME: 'B' } }; + const newPlaylistA = { attributes: { NAME: 'A' } }; + const newPlaylistB = { attributes: { NAME: 'B' } }; + const oldManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [oldPlaylistA] + }, + fr: { + playlists: [oldPlaylistB] + } + } + } + } + }; + const newManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [newPlaylistA] + }, + fr: { + playlists: [newPlaylistB] + } + } + } + } + }; + + assert.deepEqual( + getRemovedMediaGroupPlaylists({ oldManifest, newManifest }), + [], + 'nothing when all playlists are available' + ); +}); + +QUnit.test('returns old media group playlists not available in new media group playlists', function(assert) { + const oldPlaylistA = { attributes: { NAME: 'A' } }; + const oldPlaylistB = { attributes: { NAME: 'B' } }; + const oldPlaylistC = { attributes: { NAME: 'C' } }; + const oldPlaylistD = { attributes: { NAME: 'D' } }; + const newPlaylistA = { attributes: { NAME: 'A' } }; + const newPlaylistC = { attributes: { NAME: 'C' } }; + const oldManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [oldPlaylistA, oldPlaylistB] + }, + fr: { + playlists: [oldPlaylistC, oldPlaylistD] + } + } + } + } + }; + const newManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [newPlaylistA] + }, + fr: { + playlists: [newPlaylistC] + } + } + } + } + }; + + assert.deepEqual( + getRemovedMediaGroupPlaylists({ oldManifest, newManifest }), + [{ + type: 'AUDIO', + group: 'audio', + label: 'en', + playlist: oldPlaylistB + }, { + type: 'AUDIO', + group: 'audio', + label: 'fr', + playlist: oldPlaylistD + }], + 'returned old playlists not available in new playlists' + ); +}); + +QUnit.module('getIncompletePlaylists'); + +QUnit.test('returns nothing when no playlists', function(assert) { + assert.deepEqual(getIncompletePlaylists([]), [], 'nothing when no playlists'); +}); + +QUnit.test('returns nothing when no incomplete playlists', function(assert) { + const playlistA = { + attributes: { NAME: 'A' }, + segments: [{ timeline: 0 }, { timeline: 1 }, { timeline: 2 }] + }; + const playlistB = { + attributes: { NAME: 'B' }, + segments: [{ timeline: 0 }, { timeline: 1 }, { timeline: 2 }] + }; + const playlistC = { + attributes: { NAME: 'C' }, + segments: [{ timeline: 0 }, { timeline: 1 }, { timeline: 2 }] + }; + + assert.deepEqual( + getIncompletePlaylists([playlistA, playlistB, playlistC]), + [], + 'nothing when no incomplete playlists' + ); +}); + +QUnit.test('returns playlists that don\'t account for all timelines', function(assert) { + const playlistA = { + attributes: { NAME: 'A' }, + segments: [{ timeline: 0 }, { timeline: 1 }] + }; + const playlistB = { + attributes: { NAME: 'B' }, + segments: [{ timeline: 0 }, { timeline: 1 }, { timeline: 2 }] + }; + const playlistC = { + attributes: { NAME: 'C' }, + segments: [{ timeline: 0 }, { timeline: 1 }, { timeline: 2 }] + }; + const playlistD = { + attributes: { NAME: 'D' }, + segments: [{ timeline: 0 }, { timeline: 1 }] + }; + + assert.deepEqual( + getIncompletePlaylists([playlistA, playlistB, playlistC, playlistD]), + [playlistA, playlistD], + 'returns incomplete playlists' + ); +}); + +QUnit.module('getMediaGroupPlaylists'); + +QUnit.test('returns nothing when no media group playlists', function(assert) { + const manifest = { + mediaGroups: { + AUDIO: {} + } + }; + + assert.deepEqual( + getMediaGroupPlaylists(manifest), + [], + 'nothing when no media group playlists' + ); +}); + +QUnit.test('returns media group playlists', function(assert) { + const playlistEnA = { attributes: { NAME: 'A' } }; + const playlistEnB = { attributes: { NAME: 'B' } }; + const playlistEnC = { attributes: { NAME: 'C' } }; + const playlistFrA = { attributes: { NAME: 'A' } }; + const playlistFrB = { attributes: { NAME: 'B' } }; + const manifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [playlistEnA, playlistEnB, playlistEnC] + }, + fr: { + playlists: [playlistFrA, playlistFrB] + } + } + } + } + }; + + assert.deepEqual( + getMediaGroupPlaylists(manifest), + [playlistEnA, playlistEnB, playlistEnC, playlistFrA, playlistFrB], + 'returns media group playlists' + ); +}); + +QUnit.module('getMediaGroupPlaylistIdentificationObjects'); + +QUnit.test('returns nothing when no playlists', function(assert) { + const manifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [{ attributes: { NAME: 'A' } }] + }, + fr: { + playlists: [{ attributes: { NAME: 'A' } }] + } + } + } + } + }; + + assert.deepEqual( + getMediaGroupPlaylistIdentificationObjects({ playlists: [], manifest }), + [], + 'nothing when no playlists passed in' + ); +}); + +QUnit.test('returns ID objects for passed playlists', function(assert) { + const playlistEnA = { attributes: { NAME: 'A' } }; + const playlistEnB = { attributes: { NAME: 'B' } }; + const playlistEnC = { attributes: { NAME: 'C' } }; + const playlistFrA = { attributes: { NAME: 'A' } }; + const playlistFrB = { attributes: { NAME: 'B' } }; + const manifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [playlistEnA, playlistEnB, playlistEnC] + }, + fr: { + playlists: [playlistFrA, playlistFrB] + } + } + } + } + }; + + assert.deepEqual( + getMediaGroupPlaylistIdentificationObjects({ + playlists: [playlistEnB, playlistFrA], + manifest + }), + [{ + type: 'AUDIO', + group: 'audio', + label: 'en', + playlist: playlistEnB + }, { + type: 'AUDIO', + group: 'audio', + label: 'fr', + playlist: playlistFrA + }], + 'returns ID objects for passed playlists' + ); +}); + +QUnit.module('repositionSegmentsOnTimeline'); + +QUnit.test('updates segment timelines and mediaSequence numbers', function(assert) { + const segments = [{ + // first segment discontinuity case, discontinuity should not increment timelineStart + discontinuity: true, + number: 0, + timeline: 0 + }, { + number: 1, + timeline: 0 + }, { + discontinuity: true, + number: 2, + timeline: 1 + }, { + number: 3, + timeline: 1 + }]; + + repositionSegmentsOnTimeline({ segments, mediaSequenceStart: 11, timelineStart: 21 }); + + assert.deepEqual( + segments, + [ + { + discontinuity: true, + number: 11, + timeline: 21 + }, + { + number: 12, + timeline: 21 + }, + { + discontinuity: true, + number: 13, + timeline: 22 + }, + { + number: 14, + timeline: 22 + } + ], + 'updated segment timelines and mediaSequence numbers' + ); +}); + +QUnit.module('positionPlaylistOnTimeline'); + +QUnit.test('correctly positions when no old segments', function(assert) { + const oldPlaylist = { + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 2, + segments: [] + }; + const newPlaylist = { + // newly parsed playlists will appear as if starting from 0 + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 0 + }, { + number: 1, + timeline: 0 + }, { + discontinuity: true, + number: 2, + timeline: 1 + }, { + number: 3, + timeline: 1 + }] + }; + + positionPlaylistOnTimeline(oldPlaylist, newPlaylist); + + assert.deepEqual( + newPlaylist, + { + mediaSequence: 10, + discontinuitySequence: 2, + // timeline jumped because first segment discontinuity added + timeline: 3, + // discontinuityStarts added + discontinuityStarts: [0, 2], + segments: [{ + // discontinuity is added + discontinuity: true, + number: 10, + timeline: 3 + }, { + number: 11, + timeline: 3 + }, { + discontinuity: true, + number: 12, + timeline: 4 + }, { + number: 13, + timeline: 4 + }] + }, + 'correctly positioned new playlist when no old segments' + ); +}); + +QUnit.test('correctly positions when no new segments', function(assert) { + const oldPlaylist = { + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [0, 2], + segments: [{ + discontinuity: true, + number: 10, + timeline: 3 + }, { + number: 11, + timeline: 3 + }, { + discontinuity: true, + number: 12, + timeline: 4 + }, { + number: 13, + timeline: 4 + }] + }; + const newPlaylist = { + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + + positionPlaylistOnTimeline(oldPlaylist, newPlaylist); + + assert.deepEqual( + newPlaylist, + { + // last segment + 1 + mediaSequence: 14, + // account for two removed discontinuities + discontinuitySequence: 4, + // when segments are added the timeline will jump, but stick with the last seen + // timeline + timeline: 4, + // discontinuityStarts added + discontinuityStarts: [], + segments: [] + }, + 'correctly positioned new playlist when no new segments' + ); +}); + +QUnit.test('correctly positions when no old or new segments', function(assert) { + const oldPlaylist = { + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [], + segments: [] + }; + const newPlaylist = { + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + + positionPlaylistOnTimeline(oldPlaylist, newPlaylist); + + assert.deepEqual( + newPlaylist, + { + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 3, + // discontinuityStarts added + discontinuityStarts: [], + segments: [] + }, + 'correctly positioned new playlist when no old or new segments' + ); +}); + +QUnit.test('correctly positions for complete refresh', function(assert) { + const oldPlaylist = { + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [0, 2], + segments: [{ + discontinuity: true, + number: 10, + timeline: 3, + presentationTime: 100 + }, { + number: 11, + timeline: 3, + presentationTime: 102 + }, { + discontinuity: true, + number: 12, + timeline: 4, + presentationTime: 104 + }, { + number: 13, + timeline: 4, + presentationTime: 106 + }] + }; + const newPlaylist = { + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 0, + // This doesn't match any old segments and represents a gap of 2 seconds (the + // segment with presentationTime of 108 was missed). This helps test that the gap is + // ignored and this segment continues on as if it were the next to be played in the + // stream. + presentationTime: 110 + }, { + number: 1, + timeline: 0, + presentationTime: 112 + }, { + discontinuity: true, + number: 2, + timeline: 1, + presentationTime: 114 + }] + }; + + positionPlaylistOnTimeline(oldPlaylist, newPlaylist); + + assert.deepEqual( + newPlaylist, + { + // last segment + 1 + mediaSequence: 14, + // account for two removed discontinuities + discontinuitySequence: 4, + // increase the last seen timeline by one since these new segments are considered + // discontinuous with the prior ones + timeline: 5, + // discontinuityStarts added + discontinuityStarts: [0, 2], + segments: [{ + discontinuity: true, + number: 14, + timeline: 5, + presentationTime: 110 + }, { + number: 15, + timeline: 5, + presentationTime: 112 + }, { + discontinuity: true, + number: 16, + timeline: 6, + presentationTime: 114 + }] + }, + 'correctly positioned new playlist when manifest completely refreshed' + ); +}); + +QUnit.test('correctly positions when matching segment', function(assert) { + const oldPlaylist = { + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [0, 2], + segments: [{ + discontinuity: true, + number: 10, + timeline: 3, + presentationTime: 100 + }, { + number: 11, + timeline: 3, + presentationTime: 102 + }, { + number: 12, + timeline: 4, + presentationTime: 104 + }, { + number: 13, + timeline: 4, + presentationTime: 106 + }] + }; + const newPlaylist = { + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 0, + // matches second to last segment + presentationTime: 104 + }, { + number: 1, + timeline: 0, + presentationTime: 106 + }, { + discontinuity: true, + number: 2, + timeline: 1, + presentationTime: 108 + }] + }; + + positionPlaylistOnTimeline(oldPlaylist, newPlaylist); + + assert.deepEqual( + newPlaylist, + { + // matching segment's media sequence + mediaSequence: 12, + // account for one removed discontinuity + discontinuitySequence: 3, + // matching segment's timeline + timeline: 4, + discontinuityStarts: [2], + segments: [{ + number: 12, + timeline: 4, + presentationTime: 104 + }, { + number: 13, + timeline: 4, + presentationTime: 106 + }, { + discontinuity: true, + number: 14, + timeline: 5, + presentationTime: 108 + }] + }, + 'correctly positioned new playlist by segment match' + ); +}); + +QUnit.test('handles matching segment with removed segments from end', function(assert) { + const oldPlaylist = { + mediaSequence: 12, + discontinuitySequence: 3, + timeline: 4, + discontinuityStarts: [], + segments: [{ + number: 12, + timeline: 4, + presentationTime: 104 + }, { + number: 13, + timeline: 4, + presentationTime: 106 + }, { + number: 14, + timeline: 4, + presentationTime: 108 + }, { + number: 15, + timeline: 4, + presentationTime: 110 + }] + }; + const newPlaylist = { + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 0, + presentationTime: 104 + }, { + number: 1, + timeline: 0, + presentationTime: 106 + }] + // two segments missing that were in prior playlist + }; + + positionPlaylistOnTimeline(oldPlaylist, newPlaylist); + + assert.deepEqual( + newPlaylist, + { + mediaSequence: 12, + discontinuitySequence: 3, + timeline: 4, + discontinuityStarts: [], + segments: [{ + number: 12, + timeline: 4, + presentationTime: 104 + }, { + number: 13, + timeline: 4, + presentationTime: 106 + }] + }, + 'correctly positioned new playlist by segment match with removed segments' + ); +}); + +QUnit.module('positionPlaylistsOnTimeline'); + +QUnit.test('positions multiple playlists', function(assert) { + const oldPlaylistA = { + attributes: { NAME: 'A' }, + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 3, + segments: [] + }; + const oldPlaylistB = { + attributes: { NAME: 'B' }, + mediaSequence: 9, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [], + segments: [{ + number: 9, + timeline: 3, + presentationTime: 102 + }] + }; + // from empty to one segment + const newPlaylistA = { + attributes: { NAME: 'A' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 1, + presentationTime: 102 + }] + }; + // from one segment to empty + const newPlaylistB = { + attributes: { NAME: 'B' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const oldPlaylists = [oldPlaylistA, oldPlaylistB]; + const newPlaylists = [newPlaylistA, newPlaylistB]; + + positionPlaylistsOnTimeline({ oldPlaylists, newPlaylists }); + + assert.deepEqual( + newPlaylists, + [ + { + attributes: { NAME: 'A' }, + mediaSequence: 10, + discontinuitySequence: 2, + discontinuityStarts: [0], + // increased timeline + timeline: 4, + segments: [{ + // added discontinuity + discontinuity: true, + number: 10, + timeline: 4, + presentationTime: 102 + }] + }, + { + attributes: { NAME: 'B' }, + // increased mediaSequence to account for segment that fell off + mediaSequence: 10, + discontinuitySequence: 2, + discontinuityStarts: [], + // same timeline as old + timeline: 3, + segments: [] + } + ], + 'correctly positioned multiple playlists' + ); +}); + +QUnit.module('positionMediaGroupPlaylistsOnTimeline'); + +QUnit.test('positions multiple media group playlists', function(assert) { + const oldPlaylistA = { + attributes: { NAME: 'A' }, + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 3, + segments: [] + }; + const oldPlaylistB = { + attributes: { NAME: 'B' }, + mediaSequence: 9, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [], + segments: [{ + number: 9, + timeline: 3, + presentationTime: 102 + }] + }; + const oldPlaylistC = { + attributes: { NAME: 'C' }, + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 3, + segments: [] + }; + const oldPlaylistD = { + attributes: { NAME: 'D' }, + mediaSequence: 9, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [], + segments: [{ + number: 9, + timeline: 3, + presentationTime: 102 + }] + }; + // from empty to one segment + const newPlaylistA = { + attributes: { NAME: 'A' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 1, + presentationTime: 102 + }] + }; + // from one segment to empty + const newPlaylistB = { + attributes: { NAME: 'B' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + // from empty to one segment + const newPlaylistC = { + attributes: { NAME: 'C' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 1, + presentationTime: 102 + }] + }; + // from one segment to empty + const newPlaylistD = { + attributes: { NAME: 'D' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const oldManifest = { + mediaGroups: { + AUDIO: { + audio: { + // en and es have the same cases, but different playlist names to ensure that + // all media group labels are checked and updated + en: { + playlists: [oldPlaylistA, oldPlaylistB] + }, + es: { + playlists: [oldPlaylistC, oldPlaylistD] + } + } + } + } + }; + const newManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [newPlaylistA, newPlaylistB] + }, + es: { + playlists: [newPlaylistC, newPlaylistD] + } + } + } + } + }; + + positionMediaGroupPlaylistsOnTimeline({ oldManifest, newManifest }); + + assert.deepEqual( + newManifest, + { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [ + { + attributes: { NAME: 'A' }, + mediaSequence: 10, + discontinuitySequence: 2, + discontinuityStarts: [0], + // increased timeline + timeline: 4, + segments: [{ + // added discontinuity + discontinuity: true, + number: 10, + timeline: 4, + presentationTime: 102 + }] + }, + { + attributes: { NAME: 'B' }, + // increased mediaSequence to account for segment that fell off + mediaSequence: 10, + discontinuitySequence: 2, + discontinuityStarts: [], + // same timeline as old + timeline: 3, + segments: [] + } + ] + }, + es: { + playlists: [ + { + attributes: { NAME: 'C' }, + mediaSequence: 10, + discontinuitySequence: 2, + discontinuityStarts: [0], + // increased timeline + timeline: 4, + segments: [{ + // added discontinuity + discontinuity: true, + number: 10, + timeline: 4, + presentationTime: 102 + }] + }, + { + attributes: { NAME: 'D' }, + // increased mediaSequence to account for segment that fell off + mediaSequence: 10, + discontinuitySequence: 2, + discontinuityStarts: [], + // same timeline as old + timeline: 3, + segments: [] + } + ] + } + } + } + } + }, + 'correctly positioned multiple media group playlists' + ); +}); + +QUnit.module('removeMediaGroupPlaylists'); + +QUnit.test('no change if no playlists', function(assert) { + const playlist = { + attributes: { NAME: 'A' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const manifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [playlist] + } + } + } + } + }; + + removeMediaGroupPlaylists({ manifest, playlists: [] }); + + assert.deepEqual( + manifest, + { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [playlist] + } + } + } + } + }, + 'no chage when no playlists' + ); +}); + +QUnit.test('removes playlists from media group', function(assert) { + const playlistA = { + attributes: { NAME: 'A' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const playlistB = { + attributes: { NAME: 'B' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const playlistC = { + attributes: { NAME: 'C' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const manifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [playlistA, playlistB, playlistC] + } + } + } + } + }; + + removeMediaGroupPlaylists({ manifest, playlists: [playlistA, playlistC] }); + + assert.deepEqual( + manifest, + { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [playlistB] + } + } + } + } + }, + 'removed playlists' + ); +}); + +QUnit.test('removes media group if no playlists after removal', function(assert) { + const playlistA = { + attributes: { NAME: 'A' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const playlistB = { + attributes: { NAME: 'B' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const manifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [playlistA, playlistB] + } + } + } + } + }; + + removeMediaGroupPlaylists({ manifest, playlists: [playlistA, playlistB] }); + + assert.deepEqual( + manifest, + { mediaGroups: { AUDIO: {} } }, + 'removed playlists and group' + ); +}); + +QUnit.test('leaves other media groups when removing one', function(assert) { + const playlistA = { + attributes: { NAME: 'A' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const playlistB = { + attributes: { NAME: 'B' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const playlistC = { + attributes: { NAME: 'C' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const manifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [playlistA, playlistB] + }, + es: { + playlists: [playlistC] + } + } + } + } + }; + + removeMediaGroupPlaylists({ manifest, playlists: [playlistA, playlistB] }); + + assert.deepEqual( + manifest, + { + mediaGroups: { + AUDIO: { + audio: { + es: { + playlists: [playlistC] + } + } + } + } + }, + 'removed playlist and label, left other label alone' + ); +}); + +QUnit.module('positionManifestOnTimeline'); + +QUnit.test('returns manifest unchanged if no new playlists', function(assert) { + const playlist = { + attributes: { NAME: 'A' }, + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 3, + segments: [] + }; + const oldManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [] + } + } + } + }, + playlists: [playlist] + }; + const newManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [] + } + } + } + }, + playlists: [] + }; + + assert.deepEqual( + positionManifestOnTimeline({ oldManifest, newManifest }), + { + // should exclude the old playlist + playlistsToExclude: [playlist], + mediaGroupPlaylistsToExclude: [], + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [] + } + } + } + }, + playlists: [] + }, + 'returns manifest unchanged if no playlists' + ); +}); + +QUnit.test('returns manifest unchanged if no old playlists', function(assert) { + const playlist = { + attributes: { NAME: 'A' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const oldManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [] + } + } + } + }, + playlists: [] + }; + const newManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [] + } + } + } + }, + playlists: [playlist] + }; + + assert.deepEqual( + positionManifestOnTimeline({ oldManifest, newManifest }), + { + playlistsToExclude: [], + mediaGroupPlaylistsToExclude: [], + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [] + } + } + } + }, + playlists: [playlist] + }, + 'returns manifest unchanged if no playlists' + ); +}); + +QUnit.test('returns updated manifest and playlists to exclude', function(assert) { + const oldPlaylistA = { + attributes: { NAME: 'A' }, + mediaSequence: 9, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [], + segments: [{ + number: 9, + timeline: 3, + presentationTime: 102 + }] + }; + const oldPlaylistB = { + attributes: { NAME: 'B' }, + mediaSequence: 9, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [], + segments: [{ + number: 9, + timeline: 3, + presentationTime: 102 + }] + }; + const oldPlaylistC = { + attributes: { NAME: 'C' }, + mediaSequence: 9, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [], + segments: [{ + number: 9, + timeline: 3, + presentationTime: 102 + }] + }; + const oldPlaylistD = { + attributes: { NAME: 'D' }, + mediaSequence: 9, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [], + segments: [{ + number: 9, + timeline: 3, + presentationTime: 102 + }] + }; + const oldPlaylistE = { + attributes: { NAME: 'E' }, + mediaSequence: 9, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [], + segments: [{ + number: 9, + timeline: 3, + presentationTime: 102 + }] + }; + const oldPlaylistF = { + attributes: { NAME: 'F' }, + mediaSequence: 9, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [], + segments: [{ + number: 9, + timeline: 3, + presentationTime: 102 + }] + }; + // matches the first segment from prior + const newPlaylistA = { + attributes: { NAME: 'A' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 0, + presentationTime: 102 + }, { + discontinuity: true, + number: 1, + timeline: 1, + presentationTime: 104 + }] + }; + // matches the first segment from prior + const newPlaylistB = { + attributes: { NAME: 'B' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 0, + presentationTime: 102 + }, { + discontinuity: true, + number: 1, + timeline: 1, + presentationTime: 104 + }] + }; + // incomplete playlist (missing second timeline) + const newPlaylistE = { + attributes: { NAME: 'E' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 0, + presentationTime: 102 + }] + }; + // incomplete playlist (missing second timeline) + const newPlaylistF = { + attributes: { NAME: 'F' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 0, + presentationTime: 102 + }] + }; + const oldManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [oldPlaylistB, oldPlaylistD, oldPlaylistF] + } + } + } + }, + playlists: [oldPlaylistA, oldPlaylistC, oldPlaylistE] + }; + const newManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + // removed playlist D, playlist F incomplete + playlists: [newPlaylistB, newPlaylistF] + } + } + } + }, + // removed playlist C, playlist E incomplete + playlists: [newPlaylistA, newPlaylistE] + }; + + assert.deepEqual( + positionManifestOnTimeline({ oldManifest, newManifest }), + { + playlistsToExclude: [oldPlaylistC, newPlaylistE], + mediaGroupPlaylistsToExclude: [{ + group: 'audio', + label: 'en', + type: 'AUDIO', + playlist: oldPlaylistD + }, { + group: 'audio', + label: 'en', + type: 'AUDIO', + playlist: newPlaylistF + }], + mediaGroups: { + AUDIO: { + audio: { + en: { + // no playlists D or F + playlists: [{ + attributes: { NAME: 'B' }, + // increased mediaSequence to account for segment that fell off + mediaSequence: 9, + discontinuitySequence: 2, + discontinuityStarts: [1], + // same timeline as old + timeline: 3, + segments: [{ + number: 9, + timeline: 3, + presentationTime: 102 + }, { + discontinuity: true, + number: 10, + timeline: 4, + presentationTime: 104 + }] + }] + } + } + } + }, + // no playlists C or E + playlists: [{ + attributes: { NAME: 'A' }, + mediaSequence: 9, + discontinuitySequence: 2, + discontinuityStarts: [1], + // increased timeline + timeline: 3, + segments: [{ + number: 9, + timeline: 3, + presentationTime: 102 + }, { + discontinuity: true, + number: 10, + timeline: 4, + presentationTime: 104 + }] + }] + }, + 'returns excluded playlist and updated manifest, removed incomplete playlist' + ); +}); + +QUnit.test('returns updated audio only manifest and playlists to exclude', function(assert) { + const oldPlaylistB = { + attributes: { NAME: 'B' }, + mediaSequence: 9, + discontinuitySequence: 2, + timeline: 3, + discontinuityStarts: [], + segments: [{ + number: 9, + timeline: 3, + presentationTime: 102 + }] + }; + const oldPlaylistC = { + attributes: { NAME: 'C' }, + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 3, + segments: [] + }; + // from one segment to empty + const newPlaylistB = { + attributes: { NAME: 'B' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [] + }; + const oldManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [oldPlaylistB, oldPlaylistC] + } + } + } + }, + playlists: [] + }; + const newManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + // removed playlist C + playlists: [newPlaylistB] + } + } + } + }, + playlists: [] + }; + + assert.deepEqual( + positionManifestOnTimeline({ oldManifest, newManifest }), + { + playlistsToExclude: [], + mediaGroupPlaylistsToExclude: [{ + group: 'audio', + label: 'en', + type: 'AUDIO', + playlist: oldPlaylistC + }], + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [{ + attributes: { NAME: 'B' }, + // increased mediaSequence to account for segment that fell off + mediaSequence: 10, + discontinuitySequence: 2, + discontinuityStarts: [], + // same timeline as old + timeline: 3, + segments: [] + }] + } + } + } + }, + playlists: [] + }, + 'returns excluded playlist and updated audio only manifest' + ); +}); + +// In the future, there should be logic to handle the addition of playlists, but for now, +// the test exists to ensure nothing breaks before that logic is added. +QUnit.test('exludes playlists not seen before', function(assert) { + const oldPlaylistA = { + attributes: { NAME: 'A' }, + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 3, + segments: [] + }; + // from empty to two segments + const newPlaylistA = { + attributes: { NAME: 'A' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 1, + presentationTime: 102 + }, { + discontinuity: true, + number: 1, + timeline: 2, + presentationTime: 104 + }] + }; + // new playlist + const newPlaylistB = { + attributes: { NAME: 'B' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 1, + presentationTime: 102 + }, { + discontinuity: true, + number: 1, + timeline: 2, + presentationTime: 104 + }] + }; + const oldManifest = { + mediaGroups: {}, + playlists: [oldPlaylistA] + }; + const newManifest = { + mediaGroups: {}, + // added playlist B + playlists: [newPlaylistA, newPlaylistB] + }; + + assert.deepEqual( + positionManifestOnTimeline({ oldManifest, newManifest }), + { + playlistsToExclude: [], + mediaGroupPlaylistsToExclude: [], + mediaGroups: {}, + // no playlist B + playlists: [{ + attributes: { NAME: 'A' }, + mediaSequence: 10, + discontinuitySequence: 2, + discontinuityStarts: [0, 1], + // increased timeline + timeline: 4, + segments: [{ + // added discontinuity + discontinuity: true, + number: 10, + timeline: 4, + presentationTime: 102 + }, { + discontinuity: true, + number: 11, + timeline: 5, + presentationTime: 104 + }] + }] + }, + 'returns playlist and updated manifest, removed new playlist' + ); +}); + +// In the future, there should be logic to handle the addition of media group playlists, +// but for now, the test exists to ensure nothing breaks before that logic is added. +QUnit.test('does not include media group playlists not seen before', function(assert) { + const oldPlaylistA = { + attributes: { NAME: 'A' }, + mediaSequence: 10, + discontinuitySequence: 2, + timeline: 3, + segments: [] + }; + // from empty to two segments + const newPlaylistA = { + attributes: { NAME: 'A' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 1, + presentationTime: 102 + }, { + discontinuity: true, + number: 1, + timeline: 2, + presentationTime: 104 + }] + }; + // new playlist + const newPlaylistB = { + attributes: { NAME: 'B' }, + mediaSequence: 0, + discontinuitySequence: 0, + timeline: 0, + segments: [{ + number: 0, + timeline: 1, + presentationTime: 102 + }, { + discontinuity: true, + number: 1, + timeline: 2, + presentationTime: 104 + }] + }; + const oldManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + playlists: [oldPlaylistA] + } + } + } + }, + playlists: [] + }; + const newManifest = { + mediaGroups: { + AUDIO: { + audio: { + en: { + // added playlist B + playlists: [newPlaylistA, newPlaylistB] + } + } + } + }, + playlists: [] + }; + + assert.deepEqual( + positionManifestOnTimeline({ oldManifest, newManifest }), + { + mediaGroups: { + AUDIO: { + audio: { + en: { + // no playlist B + playlists: [{ + attributes: { NAME: 'A' }, + mediaSequence: 10, + discontinuitySequence: 2, + discontinuityStarts: [0, 1], + // increased timeline + timeline: 4, + segments: [{ + // added discontinuity + discontinuity: true, + number: 10, + timeline: 4, + presentationTime: 102 + }, { + discontinuity: true, + number: 11, + timeline: 5, + presentationTime: 104 + }] + }] + } + } + } + }, + playlists: [], + playlistsToExclude: [], + mediaGroupPlaylistsToExclude: [] + }, + 'returns updated manifest, removed new media group playlist' + ); +}); diff --git a/test/segment/segmentBase.test.js b/test/segment/segmentBase.test.js index 974354d6..bf8dada6 100644 --- a/test/segment/segmentBase.test.js +++ b/test/segment/segmentBase.test.js @@ -11,6 +11,7 @@ QUnit.test('sets segment to baseUrl', function(assert) { const inputAttributes = { baseUrl: 'http://www.example.com/i.fmp4', initialization: { sourceURL: 'http://www.example.com/init.fmp4' }, + periodStart: 0, type: 'static' }; @@ -21,6 +22,7 @@ QUnit.test('sets segment to baseUrl', function(assert) { }, resolvedUri: 'http://www.example.com/i.fmp4', uri: 'http://www.example.com/i.fmp4', + presentationTime: 0, number: 0 }]); }); @@ -30,6 +32,7 @@ QUnit.test('sets duration based on sourceDuration', function(assert) { baseUrl: 'http://www.example.com/i.fmp4', initialization: { sourceURL: 'http://www.example.com/init.fmp4' }, sourceDuration: 10, + periodStart: 0, type: 'static' }; @@ -42,6 +45,7 @@ QUnit.test('sets duration based on sourceDuration', function(assert) { }, resolvedUri: 'http://www.example.com/i.fmp4', uri: 'http://www.example.com/i.fmp4', + presentationTime: 0, number: 0 }]); }); @@ -59,6 +63,7 @@ QUnit.test('sets duration based on sourceDuration and not @timescale', function( initialization: { sourceURL: 'http://www.example.com/init.fmp4' }, sourceDuration: 10, timescale: 2, + periodStart: 0, type: 'static' }; @@ -71,6 +76,7 @@ QUnit.test('sets duration based on sourceDuration and not @timescale', function( }, resolvedUri: 'http://www.example.com/i.fmp4', uri: 'http://www.example.com/i.fmp4', + presentationTime: 0, number: 0 }]); }); @@ -82,6 +88,7 @@ QUnit.test('sets duration based on @duration', function(assert) { baseUrl: 'http://www.example.com/i.fmp4', initialization: { sourceURL: 'http://www.example.com/init.fmp4' }, periodIndex: 0, + periodStart: 0, type: 'static' }; @@ -94,6 +101,7 @@ QUnit.test('sets duration based on @duration', function(assert) { }, resolvedUri: 'http://www.example.com/i.fmp4', uri: 'http://www.example.com/i.fmp4', + presentationTime: 0, number: 0 }]); }); @@ -106,6 +114,7 @@ QUnit.test('sets duration based on @duration and @timescale', function(assert) { baseUrl: 'http://www.example.com/i.fmp4', initialization: { sourceURL: 'http://www.example.com/init.fmp4' }, periodIndex: 0, + periodStart: 0, type: 'static' }; @@ -118,6 +127,7 @@ QUnit.test('sets duration based on @duration and @timescale', function(assert) { }, resolvedUri: 'http://www.example.com/i.fmp4', uri: 'http://www.example.com/i.fmp4', + presentationTime: 0, number: 0 }]); }); @@ -133,6 +143,7 @@ QUnit.test('translates ranges in node', function(assert) { range: '121-125' }, periodIndex: 0, + periodStart: 0, type: 'static' }; @@ -149,6 +160,7 @@ QUnit.test('translates ranges in node', function(assert) { }, resolvedUri: 'http://www.example.com/i.fmp4', uri: 'http://www.example.com/i.fmp4', + presentationTime: 0, number: 0 }]); }); @@ -203,6 +215,7 @@ QUnit.test('generates playlist from sidx references', function(assert) { }, duration: 2, timeline: 0, + presentationTime: 0, number: 0 }]); }); diff --git a/test/segment/segmentTemplate.test.js b/test/segment/segmentTemplate.test.js index f701eba3..31d731d3 100644 --- a/test/segment/segmentTemplate.test.js +++ b/test/segment/segmentTemplate.test.js @@ -248,9 +248,9 @@ QUnit.test('parseByDuration uses endNumber and has correct duration', function(a sourceDuration: 11, duration: '4', periodIndex: 1, - endNumber: '2', type: 'static', - periodStart: 0 + periodStart: 0, + endNumber: '2' }; assert.deepEqual( diff --git a/test/toM3u8.test.js b/test/toM3u8.test.js index ca1b52fd..967a15e6 100644 --- a/test/toM3u8.test.js +++ b/test/toM3u8.test.js @@ -12,6 +12,7 @@ QUnit.test('playlists', function(assert) { duration: 0, bandwidth: 20000, periodIndex: 1, + periodStart: 0, mimeType: 'audio/mp4', type: 'static' }, @@ -24,6 +25,7 @@ QUnit.test('playlists', function(assert) { duration: 0, bandwidth: 10000, periodIndex: 1, + periodStart: 0, mimeType: 'audio/mp4', type: 'static' }, @@ -38,6 +40,7 @@ QUnit.test('playlists', function(assert) { duration: 0, bandwidth: 10000, periodIndex: 1, + periodStart: 0, mimeType: 'video/mp4', type: 'static' }, @@ -48,6 +51,7 @@ QUnit.test('playlists', function(assert) { id: '1', bandwidth: 20000, periodIndex: 1, + periodStart: 0, mimeType: 'text/vtt', type: 'static', baseUrl: 'https://www.example.com/vtt' @@ -58,6 +62,7 @@ QUnit.test('playlists', function(assert) { id: '1', bandwidth: 10000, periodIndex: 1, + periodStart: 0, mimeType: 'text/vtt', type: 'static', baseUrl: 'https://www.example.com/vtt' @@ -83,7 +88,8 @@ QUnit.test('playlists', function(assert) { NAME: '1', ['PROGRAM-ID']: 1 }, - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, endList: true, resolvedUri: '', segments: [], @@ -97,7 +103,8 @@ QUnit.test('playlists', function(assert) { NAME: '2', ['PROGRAM-ID']: 1 }, - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, endList: true, resolvedUri: '', segments: [], @@ -124,6 +131,7 @@ QUnit.test('playlists', function(assert) { ['PROGRAM-ID']: 1 }, mediaSequence: 0, + discontinuitySequence: 0, targetDuration: 100, endList: true, resolvedUri: 'https://www.example.com/vtt', @@ -132,6 +140,7 @@ QUnit.test('playlists', function(assert) { resolvedUri: 'https://www.example.com/vtt', timeline: 1, uri: 'https://www.example.com/vtt', + presentationTime: 0, number: 0 }], timeline: 1, @@ -143,6 +152,7 @@ QUnit.test('playlists', function(assert) { ['PROGRAM-ID']: 1 }, mediaSequence: 0, + discontinuitySequence: 0, targetDuration: 100, endList: true, resolvedUri: 'https://www.example.com/vtt', @@ -151,6 +161,7 @@ QUnit.test('playlists', function(assert) { resolvedUri: 'https://www.example.com/vtt', timeline: 1, uri: 'https://www.example.com/vtt', + presentationTime: 0, number: 0 }], timeline: 1, @@ -176,7 +187,8 @@ QUnit.test('playlists', function(assert) { } }, endList: true, - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, targetDuration: 0, resolvedUri: '', segments: [], @@ -187,7 +199,7 @@ QUnit.test('playlists', function(assert) { uri: '' }; - assert.deepEqual(toM3u8(input), expected); + assert.deepEqual(toM3u8({ dashPlaylists: input }), expected); }); QUnit.test('playlists with segments', function(assert) { @@ -375,7 +387,8 @@ QUnit.test('playlists with segments', function(assert) { ['PROGRAM-ID']: 1 }, targetDuration: 2, - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, endList: true, resolvedUri: '', segments: [{ @@ -387,7 +400,7 @@ QUnit.test('playlists with segments', function(assert) { uri: '', resolvedUri: '' }, - number: 1 + number: 0 }, { uri: '', timeline: 1, @@ -397,7 +410,7 @@ QUnit.test('playlists with segments', function(assert) { uri: '', resolvedUri: '' }, - number: 2 + number: 1 }], timeline: 1, uri: '' @@ -409,7 +422,8 @@ QUnit.test('playlists with segments', function(assert) { ['PROGRAM-ID']: 1 }, targetDuration: 2, - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, endList: true, resolvedUri: '', segments: [{ @@ -421,7 +435,7 @@ QUnit.test('playlists with segments', function(assert) { uri: '', resolvedUri: '' }, - number: 1 + number: 0 }, { uri: '', timeline: 1, @@ -431,7 +445,7 @@ QUnit.test('playlists with segments', function(assert) { uri: '', resolvedUri: '' }, - number: 2 + number: 1 }], timeline: 1, uri: '' @@ -455,7 +469,8 @@ QUnit.test('playlists with segments', function(assert) { }, endList: true, targetDuration: 2, - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, resolvedUri: 'https://www.example.com/vtt', segments: [{ uri: '', @@ -466,7 +481,7 @@ QUnit.test('playlists with segments', function(assert) { uri: '', resolvedUri: '' }, - number: 1 + number: 0 }, { uri: '', timeline: 1, @@ -476,7 +491,7 @@ QUnit.test('playlists with segments', function(assert) { uri: '', resolvedUri: '' }, - number: 2 + number: 1 }], timeline: 1, uri: '' @@ -488,7 +503,8 @@ QUnit.test('playlists with segments', function(assert) { }, endList: true, targetDuration: 2, - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, resolvedUri: 'https://www.example.com/vtt', segments: [{ uri: '', @@ -499,7 +515,7 @@ QUnit.test('playlists with segments', function(assert) { uri: '', resolvedUri: '' }, - number: 1 + number: 0 }, { uri: '', timeline: 1, @@ -509,7 +525,7 @@ QUnit.test('playlists with segments', function(assert) { uri: '', resolvedUri: '' }, - number: 2 + number: 1 }], timeline: 1, uri: '' @@ -535,7 +551,8 @@ QUnit.test('playlists with segments', function(assert) { }, endList: true, resolvedUri: '', - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, targetDuration: 2, segments: [{ uri: '', @@ -546,7 +563,7 @@ QUnit.test('playlists with segments', function(assert) { uri: '', resolvedUri: '' }, - number: 1 + number: 0 }, { uri: '', timeline: 1, @@ -556,7 +573,7 @@ QUnit.test('playlists with segments', function(assert) { uri: '', resolvedUri: '' }, - number: 2 + number: 1 }], timeline: 1, uri: '' @@ -565,7 +582,7 @@ QUnit.test('playlists with segments', function(assert) { uri: '' }; - assert.deepEqual(toM3u8(input), expected); + assert.deepEqual(toM3u8({ dashPlaylists: input }), expected); }); QUnit.test('playlists with sidx and sidxMapping', function(assert) { @@ -647,14 +664,23 @@ QUnit.test('playlists with sidx and sidxMapping', function(assert) { resolvedUri: 'http://example.com/sidx.mp4', duration: 2, number: 0, + presentationTime: 0, timeline: 1 }], endList: true, - mediaSequence: 1, + mediaSequence: 0, + discontinuitySequence: 0, resolvedUri: '' }]; - assert.deepEqual(toM3u8(input, null, mapping).playlists, expected); + assert.deepEqual( + toM3u8({ + dashPlaylists: input, + locations: null, + sidxMapping: mapping + }).playlists, + expected + ); }); QUnit.test('playlists without minimumUpdatePeriod dont assign default value', function(assert) { @@ -684,7 +710,7 @@ QUnit.test('playlists without minimumUpdatePeriod dont assign default value', fu uri: 'http://example.com/fmp4.mp4' }]; - assert.equal(toM3u8(input).minimumUpdatePeriod, undefined); + assert.equal(toM3u8({ dashPlaylists: input }).minimumUpdatePeriod, undefined); }); QUnit.test('playlists with minimumUpdatePeriod = 0', function(assert) { @@ -715,7 +741,7 @@ QUnit.test('playlists with minimumUpdatePeriod = 0', function(assert) { uri: 'http://example.com/fmp4.mp4' }]; - assert.equal(toM3u8(input).minimumUpdatePeriod, 0); + assert.equal(toM3u8({ dashPlaylists: input }).minimumUpdatePeriod, 0); }); QUnit.test('playlists with integer value for minimumUpdatePeriod', function(assert) { @@ -746,11 +772,15 @@ QUnit.test('playlists with integer value for minimumUpdatePeriod', function(asse uri: 'http://example.com/fmp4.mp4' }]; - assert.equal(toM3u8(input).minimumUpdatePeriod, 2000, 'converts update period to ms'); + assert.equal( + toM3u8({ dashPlaylists: input }).minimumUpdatePeriod, + 2000, + 'converts update period to ms' + ); }); QUnit.test('no playlists', function(assert) { - assert.deepEqual(toM3u8([]), {}); + assert.deepEqual(toM3u8({ dashPlaylists: [] }), {}); }); QUnit.test('dynamic playlists with suggestedPresentationDelay', function(assert) { @@ -815,7 +845,7 @@ QUnit.test('dynamic playlists with suggestedPresentationDelay', function(assert) } }]; - const output = toM3u8(input); + const output = toM3u8({ dashPlaylists: input }); assert.ok('suggestedPresentationDelay' in output); assert.deepEqual(output.suggestedPresentationDelay, 18); @@ -863,7 +893,7 @@ QUnit.test('playlists with label', function(assert) { }, segments: [] }]; - const output = toM3u8(input); + const output = toM3u8({ dashPlaylists: input }); assert.ok(label in output.mediaGroups.AUDIO.audio, 'label exists'); }); @@ -921,9 +951,9 @@ QUnit.test('608 captions', function(assert) { }, segments: [] }]; - const output = toM3u8(input); + const manifest = toM3u8({ dashPlaylists: input }); - const cc = output.mediaGroups['CLOSED-CAPTIONS'].cc; + const cc = manifest.mediaGroups['CLOSED-CAPTIONS'].cc; Object.keys(cc).forEach((key) => { assert.notOk(cc[key].autoselect, 'no autoselect'); diff --git a/test/toPlaylists.test.js b/test/toPlaylists.test.js index 535f1905..d583abb4 100644 --- a/test/toPlaylists.test.js +++ b/test/toPlaylists.test.js @@ -54,6 +54,7 @@ QUnit.test('segment base', function(assert) { attributes: { baseUrl: 'http://example.com/', periodIndex: 0, + periodStart: 0, sourceDuration: 2, type: 'static' }, @@ -66,6 +67,7 @@ QUnit.test('segment base', function(assert) { attributes: { baseUrl: 'http://example.com/', periodIndex: 0, + periodStart: 0, sourceDuration: 2, duration: 2, type: 'static' @@ -79,6 +81,7 @@ QUnit.test('segment base', function(assert) { uri: 'http://example.com/', timeline: 0, duration: 2, + presentationTime: 0, number: 0 }] }]; @@ -91,6 +94,7 @@ QUnit.test('segment base with sidx', function(assert) { attributes: { baseUrl: 'http://example.com/', periodIndex: 0, + periodStart: 0, sourceDuration: 2, indexRange: '10-19', type: 'static' @@ -104,6 +108,7 @@ QUnit.test('segment base with sidx', function(assert) { attributes: { baseUrl: 'http://example.com/', periodIndex: 0, + periodStart: 0, sourceDuration: 2, duration: 2, indexRange: '10-19', @@ -123,6 +128,7 @@ QUnit.test('segment base with sidx', function(assert) { }, timeline: 0, duration: 2, + presentationTime: 0, number: 0 } }]; diff --git a/test/utils.test.js b/test/utils.test.js index 222d8546..de9c366a 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -1,6 +1,13 @@ import { merge, values } from '../src/utils/object'; import { parseDuration } from '../src/utils/time'; -import { flatten, range, from, findIndexes } from '../src/utils/list'; +import { + flatten, + range, + from, + findIndex, + findIndexes, + includes +} from '../src/utils/list'; import { findChildren, getContent } from '../src/utils/xml'; import {DOMParser} from 'xmldom'; import {JSDOM} from 'jsdom'; @@ -175,6 +182,24 @@ QUnit.test('array-like', function(assert) { assert.deepEqual(result.length, 2); }); +QUnit.module('findIndex'); + +QUnit.test('returns first index that passes matching function', function(assert) { + assert.equal( + findIndex([1, { foo: 'bar' }, 1.5, 0, [], 3], (e) => e > 1), + 2, + 'returned correct index' + ); +}); + +QUnit.test('returns -1 when nothing passes matching function', function(assert) { + assert.equal( + findIndex([1, { foo: 'bar' }, 1.5, 0, [], 3], (e) => e > 3), + -1, + 'returned -1 for no match' + ); +}); + QUnit.module('findIndexes'); QUnit.test('index not found', function(assert) { @@ -192,6 +217,23 @@ QUnit.test('indexes found', function(assert) { ], 'b'), [1, 2]); }); +QUnit.module('includes'); + +QUnit.test('returns true if element is found', function(assert) { + const element = { foo: 'bar' }; + + assert.ok(includes([1, {}, element], element), 'true if element is included'); +}); + +QUnit.test('returns false if element is not found', function(assert) { + const element = { foo: 'bar' }; + + assert.notOk( + includes([1, {}, { foo: 'bar' }], element), + 'false if element is not included' + ); +}); + QUnit.module('xml', { beforeEach() { const parser = new DOMParser();