import { forEachMediaGroup } from '@videojs/vhs-utils/es/media-groups'; import { findIndex, union } from './utils/list'; const SUPPORTED_MEDIA_TYPES = ['AUDIO', 'SUBTITLES']; // allow one 60fps frame as leniency (arbitrarily chosen) const TIME_FUDGE = 1 / 60; /** * Given a list of timelineStarts, combines, dedupes, and sorts them. * * @param {TimelineStart[]} timelineStarts - list of timeline starts * * @return {TimelineStart[]} the combined and deduped timeline starts */ export const getUniqueTimelineStarts = (timelineStarts) => { return union(timelineStarts, ({ timeline }) => timeline) .sort((a, b) => (a.timeline > b.timeline) ? 1 : -1); }; /** * Finds the playlist with the matching NAME attribute. * * @param {Array} playlists - playlists to search through * @param {string} name - the NAME attribute to search for * * @return {Object|null} the matching playlist object, or null */ export const findPlaylistWithName = (playlists, name) => { for (let i = 0; i < playlists.length; i++) { if (playlists[i].attributes.NAME === name) { return playlists[i]; } } return null; }; /** * Gets a flattened array of media group playlists. * * @param {Object} manifest - the main manifest object * * @return {Array} the media group playlists */ export const getMediaGroupPlaylists = (manifest) => { let mediaGroupPlaylists = []; forEachMediaGroup(manifest, SUPPORTED_MEDIA_TYPES, (properties, type, group, label) => { mediaGroupPlaylists = mediaGroupPlaylists.concat(properties.playlists || []); }); return mediaGroupPlaylists; }; /** * Updates the playlist's media sequence numbers. * * @param {Object} config - options object * @param {Object} config.playlist - the playlist to update * @param {number} config.mediaSequence - the mediaSequence number to start with */ export const updateMediaSequenceForPlaylist = ({ playlist, mediaSequence }) => { playlist.mediaSequence = mediaSequence; playlist.segments.forEach((segment, index) => { segment.number = playlist.mediaSequence + index; }); }; /** * Updates the media and discontinuity sequence numbers of newPlaylists given oldPlaylists * and a complete list of timeline starts. * * If no matching playlist is found, only the discontinuity sequence number of the playlist * will be updated. * * Since early available timelines are not supported, at least one segment must be present. * * @param {Object} config - options object * @param {Object[]} oldPlaylists - the old playlists to use as a reference * @param {Object[]} newPlaylists - the new playlists to update * @param {Object} timelineStarts - all timelineStarts seen in the stream to this point */ export const updateSequenceNumbers = ({ oldPlaylists, newPlaylists, timelineStarts }) => { newPlaylists.forEach((playlist) => { playlist.discontinuitySequence = findIndex( timelineStarts, ({ timeline }) => timeline === playlist.timeline ); // Playlists NAMEs come from DASH Representation IDs, which are mandatory // (see ISO_23009-1-2012 5.3.5.2). // // If the same Representation existed in a prior Period, it will retain the same NAME. const oldPlaylist = findPlaylistWithName(oldPlaylists, playlist.attributes.NAME); if (!oldPlaylist) { // Since this is a new playlist, the media sequence values can start from 0 without // consequence. return; } // TODO better support for live SIDX // // As of this writing, mpd-parser does not support multiperiod SIDX (in live or VOD). // This is evident by a playlist only having a single SIDX reference. In a multiperiod // playlist there would need to be multiple SIDX references. In addition, live SIDX is // not supported when the SIDX properties change on refreshes. // // In the future, if support needs to be added, the merging logic here can be called // after SIDX references are resolved. For now, exit early to prevent exceptions being // thrown due to undefined references. if (playlist.sidx) { return; } // Since we don't yet support early available timelines, we don't need to support // playlists with no segments. const firstNewSegment = playlist.segments[0]; const oldMatchingSegmentIndex = findIndex(oldPlaylist.segments, (oldSegment) => Math.abs(oldSegment.presentationTime - firstNewSegment.presentationTime) < TIME_FUDGE); // No matching segment from the old playlist means the entire playlist was refreshed. // In this case the media sequence should account for this update, and the new segments // should be marked as discontinuous from the prior content, since the last prior // timeline was removed. if (oldMatchingSegmentIndex === -1) { updateMediaSequenceForPlaylist({ playlist, mediaSequence: oldPlaylist.mediaSequence + oldPlaylist.segments.length }); playlist.segments[0].discontinuity = true; playlist.discontinuityStarts.unshift(0); // No matching segment does not necessarily mean there's missing content. // // If the new playlist's timeline is the same as the last seen segment's timeline, // then a discontinuity can be added to identify that there's potentially missing // content. If there's no missing content, the discontinuity should still be rather // harmless. It's possible that if segment durations are accurate enough, that the // existence of a gap can be determined using the presentation times and durations, // but if the segment timing info is off, it may introduce more problems than simply // adding the discontinuity. // // If the new playlist's timeline is different from the last seen segment's timeline, // then a discontinuity can be added to identify that this is the first seen segment // of a new timeline. However, the logic at the start of this function that // determined the disconinuity sequence by timeline index is now off by one (the // discontinuity of the newest timeline hasn't yet fallen off the manifest...since // we added it), so the disconinuity sequence must be decremented. // // A period may also have a duration of zero, so the case of no segments is handled // here even though we don't yet support early available periods. if ((!oldPlaylist.segments.length && playlist.timeline > oldPlaylist.timeline) || (oldPlaylist.segments.length && playlist.timeline > oldPlaylist.segments[oldPlaylist.segments.length - 1].timeline)) { playlist.discontinuitySequence--; } return; } // If the first segment matched with a prior segment on a discontinuity (it's matching // on the first segment of a period), then the discontinuitySequence shouldn't be the // timeline's matching one, but instead should be the one prior, and the first segment // of the new manifest should be marked with a discontinuity. // // The reason for this special case is that discontinuity sequence shows how many // discontinuities have fallen off of the playlist, and discontinuities are marked on // the first segment of a new "timeline." Because of this, while DASH will retain that // Period while the "timeline" exists, HLS keeps track of it via the discontinuity // sequence, and that first segment is an indicator, but can be removed before that // timeline is gone. const oldMatchingSegment = oldPlaylist.segments[oldMatchingSegmentIndex]; if (oldMatchingSegment.discontinuity && !firstNewSegment.discontinuity) { firstNewSegment.discontinuity = true; playlist.discontinuityStarts.unshift(0); playlist.discontinuitySequence--; } updateMediaSequenceForPlaylist({ playlist, mediaSequence: oldPlaylist.segments[oldMatchingSegmentIndex].number }); }); }; /** * Given an old parsed manifest object and a new parsed manifest object, updates the * sequence and timing values within the new manifest to ensure that it lines up with the * old. * * @param {Array} oldManifest - the old main manifest object * @param {Array} newManifest - the new main manifest object * * @return {Object} the updated new manifest object */ export const positionManifestOnTimeline = ({ oldManifest, newManifest }) => { // Starting from v4.1.2 of the IOP, section 4.4.3.3 states: // // "MPD@availabilityStartTime and Period@start shall not be changed over MPD updates." // // This was added from https://github.com/Dash-Industry-Forum/DASH-IF-IOP/issues/160 // // Because of this change, and the difficulty of supporting periods with changing start // times, periods with changing start times are not supported. This makes the logic much // simpler, since periods with the same start time can be considerred the same period // across refreshes. // // To give an example as to the difficulty of handling periods where the start time may // change, if a single period manifest is refreshed with another manifest with a single // period, and both the start and end times are increased, then the only way to determine // if it's a new period or an old one that has changed is to look through the segments of // each playlist and determine the presentation time bounds to find a match. In addition, // if the period start changed to exceed the old period end, then there would be no // match, and it would not be possible to determine whether the refreshed period is a new // one or the old one. const oldPlaylists = oldManifest.playlists.concat(getMediaGroupPlaylists(oldManifest)); const newPlaylists = newManifest.playlists.concat(getMediaGroupPlaylists(newManifest)); // Save all seen timelineStarts to the new manifest. Although this potentially means that // there's a "memory leak" in that it will never stop growing, in reality, only a couple // of properties are saved for each seen Period. Even long running live streams won't // generate too many Periods, unless the stream is watched for decades. In the future, // this can be optimized by mapping to discontinuity sequence numbers for each timeline, // but it may not become an issue, and the additional info can be useful for debugging. newManifest.timelineStarts = getUniqueTimelineStarts([oldManifest.timelineStarts, newManifest.timelineStarts]); updateSequenceNumbers({ oldPlaylists, newPlaylists, timelineStarts: newManifest.timelineStarts }); return newManifest; };