diff --git a/src/playlist-loader/dash-main-playlist-loader.js b/src/playlist-loader/dash-main-playlist-loader.js
new file mode 100644
index 000000000..d7d2bbff2
--- /dev/null
+++ b/src/playlist-loader/dash-main-playlist-loader.js
@@ -0,0 +1,179 @@
+import PlaylistLoader from './playlist-loader.js';
+import {resolveUrl} from '../resolve-url';
+import {parse as parseMpd, parseUTCTiming} from 'mpd-parser';
+import {mergeManifest, forEachPlaylist} from './utils.js';
+
+/**
+ * An instance of the `DashMainPlaylistLoader` class is created when VHS is passed a DASH
+ * manifest. For dash main playlists are the only thing that needs to be refreshed. This
+ * is important to note as a lot of the `DashMediaPlaylistLoader` logic looks to
+ * `DashMainPlaylistLoader` for guidance.
+ *
+ * @extends PlaylistLoader
+ */
+class DashMainPlaylistLoader extends PlaylistLoader {
+
+ /**
+ * Create an instance of this class.
+ *
+ * @param {Element} uri
+ * The uri of the manifest.
+ *
+ * @param {Object} options
+ * Options that can be used, see the base class for more information.
+ */
+ constructor(uri, options) {
+ super(uri, options);
+ this.clientOffset_ = null;
+ this.clientClockOffset_ = null;
+ this.setMediaRefreshTimeout_ = this.setMediaRefreshTimeout_.bind(this);
+ }
+
+ /**
+ * Get an array of all playlists in this manifest, including media group
+ * playlists.
+ *
+ * @return {Object[]}
+ * An array of playlists.
+ */
+ playlists() {
+ const playlists = [];
+
+ forEachPlaylist(this.manifest_, (media) => {
+ playlists.push(media);
+ });
+
+ return playlists;
+ }
+
+ /**
+ * Parse a new manifest and merge it with an old one. Calls back
+ * with the merged manifest and weather or not it was updated.
+ *
+ * @param {string} manifestString
+ * A manifest string directly from the request response.
+ *
+ * @param {Function} callback
+ * A callback that takes the manifest and updated
+ *
+ * @private
+ */
+ parseManifest_(manifestString, callback) {
+ this.syncClientServerClock_(manifestString, (clientOffset) => {
+ const parsedManifest = parseMpd(manifestString, {
+ manifestUri: this.uri_,
+ clientOffset
+ });
+
+ // merge everything except for playlists, they will merge themselves
+ const mergeResult = mergeManifest(this.manifest_, parsedManifest, ['playlists', 'mediaGroups']);
+
+ // always trigger updated, as playlists will have to update themselves
+ callback(mergeResult.manifest, true);
+ });
+ }
+
+ /**
+ * Used by parsedManifest to get the client server sync offest.
+ *
+ * @param {string} manifestString
+ * A manifest string directly from the request response.
+ *
+ * @param {Function} callback
+ * A callback that takes the client offset
+ *
+ * @private
+ */
+ syncClientServerClock_(manifestString, callback) {
+ let utcTiming;
+
+ try {
+ utcTiming = parseUTCTiming(manifestString);
+ } catch (e) {
+ utcTiming = null;
+ }
+
+ // No UTCTiming element found in the mpd. Use Date header from mpd request as the
+ // server clock
+ if (utcTiming === null) {
+ return callback(this.lastRequestTime() - Date.now());
+ }
+
+ if (utcTiming.method === 'DIRECT') {
+ return callback(utcTiming.value - Date.now());
+ }
+
+ this.makeRequest_({
+ uri: resolveUrl(this.uri(), utcTiming.value),
+ method: utcTiming.method,
+ handleErrors: false
+ }, (request, wasRedirected, error) => {
+ let serverTime = this.lastRequestTime();
+
+ if (!error && utcTiming.method === 'HEAD' && request.responseHeaders && request.responseHeaders.date) {
+ serverTime = Date.parse(request.responseHeaders.date);
+ }
+
+ if (!error && request.responseText) {
+ serverTime = Date.parse(request.responseText);
+ }
+
+ callback(serverTime - Date.now());
+ });
+ }
+
+ /**
+ * Used by DashMediaPlaylistLoader in cases where
+ * minimumUpdatePeriod is zero. This allows the currently active
+ * playlist to set the mediaRefreshTime_ time to it's targetDuration.
+ *
+ * @param {number} time
+ * Set the mediaRefreshTime
+ *
+ * @private
+ */
+ setMediaRefreshTime_(time) {
+ this.mediaRefreshTime_ = time;
+ this.setMediaRefreshTimeout_();
+ }
+
+ /**
+ * Get the amount of time that should elapse before the media is
+ * re-requested. Returns null if it shouldn't be re-requested. For
+ * Dash we look at minimumUpdatePeriod (from the manifest) or the
+ * targetDuration of the currently selected media
+ * (from a DashMediaPlaylistLoader).
+ *
+ * @return {number}
+ * Returns the media refresh time
+ *
+ * @private
+ */
+ getMediaRefreshTime_() {
+ const minimumUpdatePeriod = this.manifest_.minimumUpdatePeriod;
+
+ // if minimumUpdatePeriod is invalid or <= zero, which
+ // can happen when a live video becomes VOD. We do not have
+ // a media refresh time.
+ if (typeof minimumUpdatePeriod !== 'number' || minimumUpdatePeriod < 0) {
+ return null;
+ }
+
+ // If the minimumUpdatePeriod has a value of 0, that indicates that the current
+ // MPD has no future validity, so a new one will need to be acquired when new
+ // media segments are to be made available. Thus, we use the target duration
+ // in this case
+ // TODO: can we do this in a better way? It would be much better
+ // if DashMainPlaylistLoader didn't care about media playlist loaders at all.
+ // Right now DashMainPlaylistLoader's call `setMediaRefreshTime_` to set
+ // the medias target duration.
+ if (minimumUpdatePeriod === 0) {
+ return this.mediaRefreshTime_;
+ }
+
+ return minimumUpdatePeriod;
+ }
+
+}
+
+export default DashMainPlaylistLoader;
diff --git a/src/playlist-loader/dash-media-playlist-loader.js b/src/playlist-loader/dash-media-playlist-loader.js
new file mode 100644
index 000000000..3856fef1a
--- /dev/null
+++ b/src/playlist-loader/dash-media-playlist-loader.js
@@ -0,0 +1,235 @@
+import PlaylistLoader from './playlist-loader.js';
+import containerRequest from '../util/container-request.js';
+import {addSidxSegmentsToPlaylist} from 'mpd-parser';
+import parseSidx from 'mux.js/lib/tools/parse-sidx';
+import {toUint8} from '@videojs/vhs-utils/es/byte-helpers';
+import {segmentXhrHeaders} from '../xhr';
+import {mergeMedia, forEachPlaylist} from './utils.js';
+
+/**
+ * This function is used internally to keep DashMainPlaylistLoader
+ * up to date with changes from this playlist loader.
+ *
+ * @param {Object} mainManifest
+ * The manifest from DashMainPlaylistLoader
+ *
+ * @param {string} uri
+ * The uri of the playlist to find
+ *
+ * @return {null|Object}
+ * An object with get/set functions or null.
+ */
+export const getMediaAccessor = function(mainManifest, uri) {
+ let result = null;
+
+ forEachPlaylist(mainManifest, function(media, index, array) {
+ if (media.uri === uri) {
+ result = {
+ get: () => array[index],
+ set: (v) => {
+ array[index] = v;
+ }
+ };
+
+ return true;
+ }
+ });
+
+ return result;
+};
+
+/**
+ * A class to encapsulate all of the functionality for
+ * Dash media playlists. Note that this PlaylistLoader does
+ * not refresh, parse, or have manifest strings. This is because
+ * Dash doesn't really have media playlists. We only use them because:
+ * 1. We want to match our HLS API
+ * 2. Dash does have sub playlists but everything is updated on main.
+ *
+ * @extends PlaylistLoader
+ */
+class DashMediaPlaylistLoader extends PlaylistLoader {
+ /**
+ * Create an instance of this class.
+ *
+ * @param {string} uri
+ * The uri of the manifest.
+ *
+ * @param {Object} options
+ * Options that can be used. See base class for
+ * shared options.
+ *
+ * @param {boolean} options.mainPlaylistLoader
+ * The main playlist loader this playlist exists on.
+ */
+ constructor(uri, options) {
+ super(uri, options);
+ this.manifest_ = null;
+ this.manifestString_ = null;
+
+ this.sidx_ = null;
+ this.mainPlaylistLoader_ = options.mainPlaylistLoader;
+ this.boundOnMainUpdated_ = () => this.onMainUpdated_();
+
+ this.mainPlaylistLoader_.on('updated', this.boundOnMainUpdated_);
+
+ // turn off event watching from parent
+ this.off('refresh', this.refreshManifest_);
+ this.off('updated', this.setMediaRefreshTimeout_);
+ }
+
+ // noop, as media playlists in dash do not have
+ // a uri to refresh or a manifest string
+ refreshManifest_() {}
+ parseManifest_() {}
+ setMediaRefreshTimeout_() {}
+ clearMediaRefreshTimeout_() {}
+ getMediaRefreshTime_() {}
+ getManifestString_() {}
+
+ /**
+ * A function that is run when main updates, but only
+ * functions if this playlist loader is started. It will
+ * merge it's old manifest with the new one, and update it
+ * with sidx segments if needed.
+ *
+ * @listens {DashMainPlaylistLoader#updated}
+ * @private
+ */
+ onMainUpdated_() {
+ if (!this.started_) {
+ return;
+ }
+
+ // save our old media information
+ const oldMedia = this.manifest_;
+
+ // get the newly updated media information
+ const mediaAccessor = getMediaAccessor(
+ this.mainPlaylistLoader_.manifest(),
+ this.uri()
+ );
+
+ if (!mediaAccessor) {
+ this.triggerError_('could not find playlist on mainPlaylistLoader');
+ return;
+ }
+
+ // use them
+ const newMedia = this.manifest_ = mediaAccessor.get();
+
+ this.requestSidx_(() => {
+ if (newMedia.sidx && this.sidx_) {
+ addSidxSegmentsToPlaylist(newMedia, this.sidx_, newMedia.sidx.resolvedUri);
+ }
+ newMedia.id = newMedia.id || newMedia.attributes.NAME;
+ newMedia.uri = newMedia.uri || newMedia.attributes.NAME;
+
+ const {media, updated} = mergeMedia({
+ oldMedia,
+ newMedia,
+ uri: this.mainPlaylistLoader_.uri()
+ });
+
+ // set the newly merged media on main
+ mediaAccessor.set(media);
+ this.manifest_ = mediaAccessor.get();
+
+ if (updated) {
+ this.mainPlaylistLoader_.setMediaRefreshTime_(this.manifest().targetDuration * 1000);
+ this.trigger('updated');
+ }
+ });
+ }
+
+ /**
+ * A function that is run when main updates, but only
+ * functions if this playlist loader is started. It will
+ * merge it's old manifest with the new one, and update it
+ * with sidx segments if needed.
+ *
+ * @listens {DashMainPlaylistLoader#updated}
+ * @private
+ */
+ requestSidx_(callback) {
+ if ((this.sidx_ && this.manifest_.sidx) || !this.manifest_.sidx) {
+ return callback();
+ }
+ const uri = this.manifest_.sidx.resolvedUri;
+
+ const parseSidx_ = (request, wasRedirected) => {
+ let sidx;
+
+ try {
+ sidx = parseSidx(toUint8(request.response).subarray(8));
+ } catch (e) {
+ // sidx parsing failed.
+ this.triggerError_(e);
+ return;
+ }
+
+ this.sidx_ = sidx;
+ callback();
+
+ };
+
+ this.request_ = containerRequest(uri, this.vhs_.xhr, (error, request, container, bytes) => {
+ this.request_ = null;
+
+ if (error || !container || container !== 'mp4') {
+ if (error) {
+ this.triggerError_(error);
+ } else {
+ container = container || 'unknown';
+ this.triggerError_(`Unsupported ${container} container type for sidx segment at URL: ${uri}`);
+ }
+ return;
+ }
+
+ // if we already downloaded the sidx bytes in the container request, use them
+ const {offset, length} = this.manifest_.sidx.byterange;
+
+ if (bytes.length >= (length + offset)) {
+ return parseSidx_({
+ response: bytes.subarray(offset, offset + length),
+ status: request.status,
+ uri: request.uri
+ });
+ }
+
+ // otherwise request sidx bytes
+ this.makeRequest_({
+ uri,
+ responseType: 'arraybuffer',
+ headers: segmentXhrHeaders({byterange: this.manifest_.sidx.byterange})
+ }, parseSidx_);
+ });
+ }
+
+ start() {
+ if (this.started_) {
+ return;
+ }
+
+ this.started_ = true;
+ this.onMainUpdated_();
+ }
+
+ stop() {
+ if (!this.started_) {
+ return;
+ }
+
+ this.manifest_ = null;
+ // reset media refresh time
+ this.mainPlaylistLoader_.setMediaRefreshTime_(null);
+ super.stop();
+ }
+
+ dispose() {
+ this.mainPlaylistLoader_.off('updated', this.boundOnMainUpdated_);
+ super.dispose();
+ }
+}
+
+export default DashMediaPlaylistLoader;
diff --git a/src/playlist-loader/hls-main-playlist-loader.js b/src/playlist-loader/hls-main-playlist-loader.js
new file mode 100644
index 000000000..909a1529c
--- /dev/null
+++ b/src/playlist-loader/hls-main-playlist-loader.js
@@ -0,0 +1,35 @@
+import PlaylistLoader from './playlist-loader.js';
+import {parseManifest} from '../manifest.js';
+
+/**
+ * A class to encapsulate all of the functionality for
+ * Hls main playlists.
+ *
+ * @extends PlaylistLoader
+ */
+class HlsMainPlaylistLoader extends PlaylistLoader {
+ parseManifest_(manifestString, callback) {
+ const parsedManifest = parseManifest({
+ onwarn: ({message}) => this.logger_(`m3u8-parser warn for ${this.uri_}: ${message}`),
+ oninfo: ({message}) => this.logger_(`m3u8-parser info for ${this.uri_}: ${message}`),
+ manifestString,
+ customTagParsers: this.options_.customTagParsers,
+ customTagMappers: this.options_.customTagMappers,
+ experimentalLLHLS: this.options_.experimentalLLHLS
+ });
+
+ callback(parsedManifest, this.manifestString_ !== manifestString);
+ }
+
+ start() {
+ // never re-request the manifest.
+ if (this.manifest_) {
+ this.started_ = true;
+ return;
+ }
+
+ super.start();
+ }
+}
+
+export default HlsMainPlaylistLoader;
diff --git a/src/playlist-loader/hls-media-playlist-loader.js b/src/playlist-loader/hls-media-playlist-loader.js
new file mode 100644
index 000000000..4874dc80e
--- /dev/null
+++ b/src/playlist-loader/hls-media-playlist-loader.js
@@ -0,0 +1,183 @@
+import PlaylistLoader from './playlist-loader.js';
+import {parseManifest} from '../manifest.js';
+import {mergeMedia} from './utils.js';
+
+/**
+ * Calculates the time to wait before refreshing a live playlist
+ *
+ * @param {Object} manifest
+ * The current media.
+ *
+ * @param {boolean} update
+ * True if there were any updates from the last refresh, false otherwise
+ *
+ * @return {number}
+ * The time in ms to wait before refreshing the live playlist
+ */
+export const timeBeforeRefresh = function(manifest, update) {
+ const lastSegment = manifest.segments && manifest.segments[manifest.segments.length - 1];
+ const lastPart = lastSegment && lastSegment.parts && lastSegment.parts[lastSegment.parts.length - 1];
+ const lastDuration = lastPart && lastPart.duration || lastSegment && lastSegment.duration;
+
+ if (update && lastDuration) {
+ return lastDuration * 1000;
+ }
+
+ // if the playlist is unchanged since the last reload or last segment duration
+ // cannot be determined, try again after half the target duration
+ return (manifest.partTargetDuration || manifest.targetDuration || 10) * 500;
+};
+
+/**
+ * Clone a preload segment so that we can add it to segments
+ * without worrying about adding properties and messing up the
+ * mergeMedia update algorithm.
+ *
+ * @param {Object} [preloadSegment={}]
+ * The preloadSegment to clone
+ *
+ * @return {Object}
+ * The cloned preload segment.
+ */
+const clonePreloadSegment = (preloadSegment) => {
+ preloadSegment = preloadSegment || {};
+ const result = Object.assign({}, preloadSegment);
+
+ if (preloadSegment.parts) {
+ result.parts = [];
+ for (let i = 0; i < preloadSegment.parts.length; i++) {
+ // clone the part
+ result.parts.push(Object.assign({}, preloadSegment.parts[i]));
+ }
+ }
+
+ if (preloadSegment.preloadHints) {
+ result.preloadHints = [];
+ for (let i = 0; i < preloadSegment.preloadHints.length; i++) {
+ // clone the preload hint
+ result.preloadHints.push(Object.assign({}, preloadSegment.preloadHints[i]));
+ }
+ }
+
+ return result;
+};
+
+/**
+ * A helper function so that we can add preloadSegments
+ * that have parts and skipped segments to our main
+ * segment array.
+ *
+ * @param {Object} manifest
+ * The manifest to get all segments for.
+ *
+ * @return {Array}
+ * An array of segments.
+ */
+export const getAllSegments = function(manifest) {
+ const segments = manifest.segments || [];
+ let preloadSegment = manifest.preloadSegment;
+
+ // a preloadSegment with only preloadHints is not currently
+ // a usable segment, only include a preloadSegment that has
+ // parts.
+ if (preloadSegment && preloadSegment.parts && preloadSegment.parts.length) {
+ let add = true;
+
+ // if preloadHints has a MAP that means that the
+ // init segment is going to change. We cannot use any of the parts
+ // from this preload segment.
+ if (preloadSegment.preloadHints) {
+ for (let i = 0; i < preloadSegment.preloadHints.length; i++) {
+ if (preloadSegment.preloadHints[i].type === 'MAP') {
+ add = false;
+ break;
+ }
+ }
+ }
+
+ if (add) {
+ preloadSegment = clonePreloadSegment(preloadSegment);
+
+ // set the duration for our preload segment to target duration.
+ preloadSegment.duration = manifest.targetDuration;
+ preloadSegment.preload = true;
+
+ segments.push(preloadSegment);
+ }
+ }
+
+ if (manifest.skip) {
+ manifest.segments = manifest.segments || [];
+ // add back in objects for skipped segments, so that we merge
+ // old properties into the new segments
+ for (let i = 0; i < manifest.skip.skippedSegments; i++) {
+ manifest.segments.unshift({skipped: true});
+ }
+ }
+
+ return segments;
+};
+
+/**
+ * A small wrapped around parseManifest that also does
+ * getAllSegments.
+ *
+ * @param {Object} options
+ * options to pass to parseManifest.
+ *
+ * @return {Object}
+ * The parsed manifest.
+ */
+const parseManifest_ = function(options) {
+ const parsedMedia = parseManifest(options);
+
+ // TODO: this should go in parseManifest in manifest.js, as it
+ // always needs to happen directly afterwards
+ parsedMedia.segments = getAllSegments(parsedMedia);
+
+ return parsedMedia;
+};
+
+/**
+ * A class to encapsulate all of the functionality for
+ * Hls media playlists.
+ *
+ * @extends PlaylistLoader
+ */
+class HlsMediaPlaylistLoader extends PlaylistLoader {
+
+ parseManifest_(manifestString, callback) {
+ const parsedMedia = parseManifest_({
+ onwarn: ({message}) => this.logger_(`m3u8-parser warn for ${this.uri()}: ${message}`),
+ oninfo: ({message}) => this.logger_(`m3u8-parser info for ${this.uri()}: ${message}`),
+ manifestString,
+ customTagParsers: this.options_.customTagParsers,
+ customTagMappers: this.options_.customTagMappers,
+ experimentalLLHLS: this.options_.experimentalLLHLS
+ });
+
+ const {media, updated} = mergeMedia({
+ oldMedia: this.manifest_,
+ newMedia: parsedMedia,
+ baseUri: this.uri()
+ });
+
+ this.mediaRefreshTime_ = timeBeforeRefresh(media, updated);
+
+ callback(media, updated);
+ }
+
+ start() {
+ // if we already have a vod manifest then we never
+ // need to re-request it.
+ if (this.manifest() && this.manifest().endList) {
+ this.started_ = true;
+ return;
+ }
+
+ super.start();
+ }
+
+}
+
+export default HlsMediaPlaylistLoader;
diff --git a/src/playlist-loader/playlist-loader.js b/src/playlist-loader/playlist-loader.js
new file mode 100644
index 000000000..c1d9b07b6
--- /dev/null
+++ b/src/playlist-loader/playlist-loader.js
@@ -0,0 +1,337 @@
+import videojs from 'video.js';
+import logger from '../util/logger';
+import window from 'global/window';
+
+/**
+ * A base class for PlaylistLoaders that seeks to encapsulate all the
+ * shared functionality from dash and hls.
+ *
+ * @extends videojs.EventTarget
+ */
+class PlaylistLoader extends videojs.EventTarget {
+
+ /**
+ * Create an instance of this class.
+ *
+ * @param {string} uri
+ * The uri of the manifest.
+ *
+ * @param {Object} options
+ * Options that can be used.
+ *
+ * @param {Object} options.vhs
+ * The VHS object, used for it's xhr
+ *
+ * @param {Object} [options.manifest]
+ * A starting manifest object.
+ *
+ * @param {Object} [options.manifestString]
+ * The raw manifest string.
+ *
+ * @param {number} [options.lastRequestTime]
+ * The last request time.
+ *
+ * @param {boolean} [options.withCredentials=false]
+ * If requests should be sent withCredentials or not.
+ *
+ * @param {boolean} [options.handleManifestRedirects=false]
+ * If manifest redirects should change the internal uri
+ */
+ constructor(uri, options = {}) {
+ super();
+ this.logger_ = logger(this.constructor.name || 'PlaylistLoader');
+ this.uri_ = uri;
+ this.options_ = options;
+ this.manifest_ = options.manifest || null;
+ this.vhs_ = options.vhs;
+ this.manifestString_ = options.manifestString || null;
+ this.lastRequestTime_ = options.lastRequestTime || null;
+
+ this.mediaRefreshTime_ = null;
+ this.mediaRefreshTimeout_ = null;
+ this.request_ = null;
+ this.started_ = false;
+ this.on('refresh', this.refreshManifest_);
+ this.on('updated', this.setMediaRefreshTimeout_);
+ }
+
+ /**
+ * A getter for the current error object.
+ *
+ * @return {Object|null}
+ * The current error or null.
+ */
+ error() {
+ return this.error_;
+ }
+
+ /**
+ * A getter for the current request object.
+ *
+ * @return {Object|null}
+ * The current request or null.
+ */
+ request() {
+ return this.request_;
+ }
+
+ /**
+ * A getter for the uri string.
+ *
+ * @return {string}
+ * The uri.
+ */
+ uri() {
+ return this.uri_;
+ }
+
+ /**
+ * A getter for the manifest object.
+ *
+ * @return {Object|null}
+ * The manifest or null.
+ */
+ manifest() {
+ return this.manifest_;
+ }
+
+ /**
+ * Determine if the loader is started or not.
+ *
+ * @return {boolean}
+ * True if stared, false otherwise.
+ */
+ started() {
+ return this.started_;
+ }
+
+ /**
+ * The last time a request happened.
+ *
+ * @return {number|null}
+ * The last request time or null.
+ */
+ lastRequestTime() {
+ return this.lastRequestTime_;
+ }
+
+ /**
+ * A function that is called to when the manifest should be
+ * re-requested and parsed.
+ *
+ * @listens {PlaylistLoader#updated}
+ * @private
+ */
+ refreshManifest_() {
+ this.makeRequest_({uri: this.uri()}, (request, wasRedirected) => {
+ if (wasRedirected) {
+ this.uri_ = request.responseURL;
+ }
+
+ if (request.responseHeaders && request.responseHeaders.date) {
+ this.lastRequestTime_ = Date.parse(request.responseHeaders.date);
+ } else {
+ this.lastRequestTime_ = Date.now();
+ }
+
+ this.parseManifest_(request.responseText, (parsedManifest, updated) => {
+ if (updated) {
+ this.manifestString_ = request.responseText;
+ this.manifest_ = parsedManifest;
+ this.trigger('updated');
+ }
+ });
+ });
+ }
+
+ /**
+ * A function that is called to when the manifest should be
+ * parsed and merged.
+ *
+ * @param {string} manifestText
+ * The text of the manifest directly from a request.
+ *
+ * @param {Function} callback
+ * The callback that takes two arguments. The parsed
+ * and merged manifest, and weather or not that manifest
+ * was updated.
+ *
+ * @private
+ */
+ parseManifest_(manifestText, callback) {}
+
+ /**
+ * A function that is called when a playlist loader needs to
+ * make a request of any kind. Uses `withCredentials` from the
+ * constructor, but can be overriden if needed.
+ *
+ * @param {Object} options
+ * Options for the request.
+ *
+ * @param {string} options.uri
+ * The uri to request.
+ *
+ * @param {boolean} [options.handleErrors=true]
+ * If errors should trigger on the playlist loader. If
+ * This is false, errors will be passed along.
+ *
+ * @param {boolean} [options.withCredentials=false]
+ * If this request should be sent withCredentials. Defaults
+ * to the value passed in the constructor or false.
+ *
+ * @param {Function} callback
+ * The callback that takes three arguments. 1 the request,
+ * 2 if we were redirected, and 3 error
+ *
+ * @private
+ */
+ makeRequest_(options, callback) {
+ if (!this.started_) {
+ this.triggerError_('makeRequest_ cannot be called before started!');
+ return;
+ }
+
+ const xhrOptions = videojs.mergeOptions({withCredentials: this.options_.withCredentials}, options);
+ let handleErrors = true;
+
+ if (xhrOptions.hasOwnProperty('handleErrors')) {
+ handleErrors = xhrOptions.handleErrors;
+ delete xhrOptions.handleErrors;
+ }
+
+ this.request_ = this.options_.vhs.xhr(xhrOptions, (error, request) => {
+ // disposed
+ if (this.isDisposed_) {
+ return;
+ }
+
+ // successful or errored requests are finished.
+ this.request_ = null;
+
+ const wasRedirected = Boolean(this.options_.handleManifestRedirects &&
+ request.responseURL !== xhrOptions.uri);
+
+ if (error && handleErrors) {
+ this.triggerError_(`Request error at URI ${request.uri}`);
+ return;
+ }
+
+ callback(request, wasRedirected, error);
+ });
+ }
+
+ /**
+ * Trigger an error on this playlist loader.
+ *
+ * @param {Object|string} error
+ * The error object or string
+ *
+ * @private
+ */
+ triggerError_(error) {
+ if (typeof error === 'string') {
+ error = {message: error};
+ }
+
+ this.error_ = error;
+ this.trigger('error');
+ this.stop();
+ }
+
+ /**
+ * Start the loader
+ */
+ start() {
+ if (!this.started_) {
+ this.started_ = true;
+ this.refreshManifest_();
+ }
+ }
+
+ /**
+ * Stop the loader
+ */
+ stop() {
+ if (this.started_) {
+ this.started_ = false;
+ this.stopRequest();
+ this.clearMediaRefreshTimeout_();
+ }
+ }
+
+ /**
+ * Stop any requests on the loader
+ */
+ stopRequest() {
+ if (this.request_) {
+ this.request_.onreadystatechange = null;
+ this.request_.abort();
+ this.request_ = null;
+ }
+ }
+
+ /**
+ * clear the media refresh timeout
+ *
+ * @private
+ */
+ clearMediaRefreshTimeout_() {
+ if (this.refreshTimeout_) {
+ window.clearTimeout(this.refreshTimeout_);
+ this.refreshTimeout_ = null;
+ }
+ }
+
+ /**
+ * Set or clear the media refresh timeout based on
+ * what getMediaRefreshTime_ returns.
+ *
+ * @listens {PlaylistLoader#updated}
+ * @private
+ */
+ setMediaRefreshTimeout_() {
+ // do nothing if disposed
+ if (this.isDisposed_) {
+ return;
+ }
+ const time = this.getMediaRefreshTime_();
+
+ this.clearMediaRefreshTimeout_();
+
+ if (typeof time !== 'number') {
+ this.logger_('Not setting media refresh time, as time given is not a number.');
+ return;
+ }
+
+ this.refreshTimeout_ = window.setTimeout(() => {
+ this.refreshTimeout_ = null;
+ this.trigger('refresh');
+ this.setMediaRefreshTimeout_();
+ }, time);
+ }
+
+ /**
+ * Get the amount of time to let elapsed before refreshing
+ * the manifest.
+ *
+ * @return {number|null}
+ * The media refresh time in milliseconds.
+ * @private
+ */
+ getMediaRefreshTime_() {
+ return this.mediaRefreshTime_;
+ }
+
+ /**
+ * Dispose and cleanup this playlist loader.
+ *
+ * @private
+ */
+ dispose() {
+ this.isDisposed_ = true;
+ this.stop();
+ this.trigger('dispose');
+ }
+}
+
+export default PlaylistLoader;
diff --git a/src/playlist-loader/utils.js b/src/playlist-loader/utils.js
new file mode 100644
index 000000000..2c6f2ab7a
--- /dev/null
+++ b/src/playlist-loader/utils.js
@@ -0,0 +1,367 @@
+import {mergeOptions} from 'video.js';
+import {resolveUrl} from '../resolve-url';
+import deepEqual from '../util/deep-equal.js';
+
+/**
+ * Get aboslute uris for all uris on a segment unless
+ * they are already resolved.
+ *
+ * @param {Object} segment
+ * The segment to get aboslute uris for.
+ *
+ * @param {string} baseUri
+ * The base uri to use for resolving.
+ *
+ * @return {Object}
+ * The segment with resolved uris.
+ */
+const resolveSegmentUris = function(segment, baseUri = '') {
+ // preloadSegment will not have a uri at all
+ // as the segment isn't actually in the manifest yet, only parts
+ if (!segment.resolvedUri && segment.uri) {
+ segment.resolvedUri = resolveUrl(baseUri, segment.uri);
+ }
+ if (segment.key && !segment.key.resolvedUri) {
+ segment.key.resolvedUri = resolveUrl(baseUri, segment.key.uri);
+ }
+ if (segment.map && !segment.map.resolvedUri) {
+ segment.map.resolvedUri = resolveUrl(baseUri, segment.map.uri);
+ }
+
+ if (segment.map && segment.map.key && !segment.map.key.resolvedUri) {
+ segment.map.key.resolvedUri = resolveUrl(baseUri, segment.map.key.uri);
+ }
+ if (segment.parts && segment.parts.length) {
+ segment.parts.forEach((p) => {
+ if (p.resolvedUri) {
+ return;
+ }
+ p.resolvedUri = resolveUrl(baseUri, p.uri);
+ });
+ }
+
+ if (segment.preloadHints && segment.preloadHints.length) {
+ segment.preloadHints.forEach((p) => {
+ if (p.resolvedUri) {
+ return;
+ }
+ p.resolvedUri = resolveUrl(baseUri, p.uri);
+ });
+ }
+
+ return segment;
+};
+
+/**
+ * Returns a new segment object with properties and
+ * the parts array merged.
+ *
+ * @param {Object} a
+ * The old segment
+ *
+ * @param {Object} b
+ * The new segment
+ *
+ * @return {Object}
+ * The merged segment and if it was updated.
+ */
+export const mergeSegment = function(a, b) {
+ let segment = b;
+ let updated = false;
+
+ if (!a) {
+ updated = true;
+ }
+
+ a = a || {};
+ b = b || {};
+
+ segment = mergeOptions(a, b);
+
+ // if only the old segment has preload hints
+ // and the new one does not, remove preload hints.
+ if (a.preloadHints && !b.preloadHints) {
+ updated = true;
+ delete segment.preloadHints;
+ }
+
+ // if only the old segment has parts
+ // then the parts are no longer valid
+ if (a.parts && !b.parts) {
+ updated = true;
+ delete segment.parts;
+ // if both segments have parts
+ // copy part propeties from the old segment
+ // to the new one.
+ } else if (a.parts && b.parts) {
+ if (a.parts.length !== b.parts.length) {
+ updated = true;
+ }
+ for (let i = 0; i < b.parts.length; i++) {
+ if (a.parts && a.parts[i]) {
+ segment.parts[i] = mergeOptions(a.parts[i], b.parts[i]);
+ }
+ }
+ }
+
+ // set skipped to false for segments that have
+ // have had information merged from the old segment.
+ if (!a.skipped && b.skipped) {
+ delete segment.skipped;
+ }
+
+ // set preload to false for segments that have
+ // had information added in the new segment.
+ if (a.preload && !b.preload) {
+ updated = true;
+ delete segment.preload;
+ }
+
+ return {updated, segment};
+};
+
+/**
+ * Merge two segment arrays.
+ *
+ * @param {Object} options
+ * options for this function
+ *
+ * @param {Object[]} options.oldSegments
+ * old segments
+ *
+ * @param {Object[]} options.newSegments
+ * new segments
+ *
+ * @param {string} options.baseUri
+ * The absolute uri to base aboslute segment uris on.
+ *
+ * @param {number} [options.offset=0]
+ * The segment offset that should be used to match old
+ * segments to new segments. IE: media sequence segment 1
+ * is segment zero in new and segment 1 in old. Offset would
+ * then be 1.
+ *
+ * @return {Object[]}
+ * The merged segment array.
+ */
+export const mergeSegments = function({oldSegments, newSegments, offset = 0, baseUri}) {
+ oldSegments = oldSegments || [];
+ newSegments = newSegments || [];
+ const result = {
+ segments: [],
+ updated: false
+ };
+
+ if (!oldSegments || !oldSegments.length || oldSegments.length !== newSegments.length) {
+ result.updated = true;
+ }
+
+ let currentMap;
+
+ for (let newIndex = 0; newIndex < newSegments.length; newIndex++) {
+ const oldSegment = oldSegments[newIndex + offset];
+ const newSegment = newSegments[newIndex];
+
+ const {updated, segment} = mergeSegment(oldSegment, newSegment);
+
+ if (updated) {
+ result.updated = updated;
+ }
+
+ const mergedSegment = segment;
+
+ // save and or carry over the map
+ if (mergedSegment.map) {
+ currentMap = mergedSegment.map;
+ } else if (currentMap && !mergedSegment.map) {
+ result.updated = true;
+ mergedSegment.map = currentMap;
+ }
+
+ result.segments.push(resolveSegmentUris(mergedSegment, baseUri));
+ }
+ return result;
+};
+
+const MEDIA_GROUP_TYPES = ['AUDIO', 'SUBTITLES'];
+
+/**
+ * Loops through all supported media groups in mainManifest and calls the provided
+ * callback for each group. Unless true is returned from the callback.
+ *
+ * @param {Object} mainManifest
+ * The parsed main manifest object
+ *
+ * @param {Function} callback
+ * Callback to call for each media group,
+ * *NOTE* The return value is used here. Any true
+ * value will stop the loop.
+ */
+export const forEachMediaGroup = (mainManifest, callback) => {
+ if (!mainManifest || !mainManifest.mediaGroups) {
+ return;
+ }
+
+ for (let i = 0; i < MEDIA_GROUP_TYPES.length; i++) {
+ const mediaType = MEDIA_GROUP_TYPES[i];
+
+ if (!mainManifest.mediaGroups[mediaType]) {
+ continue;
+ }
+ for (const groupKey in mainManifest.mediaGroups[mediaType]) {
+ if (!mainManifest.mediaGroups[mediaType][groupKey]) {
+ continue;
+ }
+ for (const labelKey in mainManifest.mediaGroups[mediaType][groupKey]) {
+ if (!mainManifest.mediaGroups[mediaType][groupKey][labelKey]) {
+ continue;
+ }
+ const mediaProperties = mainManifest.mediaGroups[mediaType][groupKey][labelKey];
+
+ const stop = callback(mediaProperties, mediaType, groupKey, labelKey);
+
+ if (stop) {
+ return;
+ }
+ }
+ }
+ }
+};
+
+/**
+ * Loops through all playlists including media group playlists and runs the
+ * callback for each one. Unless true is returned from the callback.
+ *
+ * @param {Object} mainManifest
+ * The parsed main manifest object
+ *
+ * @param {Function} callback
+ * Callback to call for each playlist
+ * *NOTE* The return value is used here. Any true
+ * value will stop the loop.
+ */
+export const forEachPlaylist = function(mainManifest, callback) {
+ if (!mainManifest) {
+ return;
+ }
+ if (mainManifest.playlists) {
+ for (let i = 0; i < mainManifest.playlists.length; i++) {
+ const stop = callback(mainManifest.playlists[i], i, mainManifest.playlists);
+
+ if (stop) {
+ return;
+ }
+ }
+ }
+
+ forEachMediaGroup(mainManifest, function(properties, type, group, label) {
+ if (!properties.playlists) {
+ return;
+ }
+
+ for (let i = 0; i < properties.playlists.length; i++) {
+ const stop = callback(properties.playlists[i], i, properties.playlists);
+
+ if (stop) {
+ return stop;
+ }
+ }
+ });
+};
+
+/**
+ * Shallow merge for an object and report if an update occured.
+ *
+ * @param {Object} a
+ * The old manifest
+ *
+ * @param {Object} b
+ * The new manifest
+ *
+ * @param {string[]} excludeKeys
+ * An array of keys to completly ignore.
+ * *NOTE* properties from the new manifest will still
+ * exist, even though they were ignored
+ *
+ * @return {Object}
+ * The merged manifest and if it was updated.
+ */
+export const mergeManifest = function(a, b, excludeKeys) {
+ excludeKeys = excludeKeys || [];
+
+ let updated = !a;
+ const mergedManifest = b;
+
+ a = a || {};
+ b = b || {};
+
+ const keys = [];
+
+ Object.keys(a).concat(Object.keys(b)).forEach(function(key) {
+ // make keys unique and exclude specified keys
+ if (excludeKeys.indexOf(key) !== -1 || keys.indexOf(key) !== -1) {
+ return;
+ }
+ keys.push(key);
+ });
+
+ keys.forEach(function(key) {
+ // both have the key
+ if (a.hasOwnProperty(key) && b.hasOwnProperty(key)) {
+ // if the value is different media was updated
+ if (!deepEqual(a[key], b[key])) {
+ updated = true;
+ }
+ // regardless grab the value from the new object
+ mergedManifest[key] = b[key];
+ // only oldMedia has the key don't bring it over, but media was updated
+ } else if (a.hasOwnProperty(key) && !b.hasOwnProperty(key)) {
+ updated = true;
+ // otherwise the key came from newMedia
+ } else {
+ updated = true;
+ mergedManifest[key] = b[key];
+ }
+ });
+
+ return {manifest: mergedManifest, updated};
+};
+
+/**
+ * Shallow merge a media manifest and deep merge it's segments.
+ *
+ * @param {Object} options
+ * The options for this function
+ *
+ * @param {Object} options.oldMedia
+ * The old media
+ *
+ * @param {Object} options.newMedia
+ * The new media
+ *
+ * @param {string} options.baseUri
+ * The base uri used for resolving aboslute uris.
+ *
+ * @return {Object}
+ * The merged media and if it was updated.
+ */
+export const mergeMedia = function({oldMedia, newMedia, baseUri}) {
+ const mergeResult = mergeManifest(oldMedia, newMedia, ['segments']);
+
+ // we need to update segments because we store timing information on them,
+ // and we also want to make sure we preserve old segment information in cases
+ // were the newMedia skipped segments.
+ const segmentResult = mergeSegments({
+ oldSegments: oldMedia && oldMedia.segments,
+ newSegments: newMedia && newMedia.segments,
+ baseUri,
+ offset: oldMedia ? (newMedia.mediaSequence - oldMedia.mediaSequence) : 0
+ });
+
+ mergeResult.manifest.segments = segmentResult.segments;
+
+ return {
+ updated: mergeResult.updated || segmentResult.updated,
+ media: mergeResult.manifest
+ };
+};
diff --git a/src/util/deep-equal.js b/src/util/deep-equal.js
new file mode 100644
index 000000000..d52478cba
--- /dev/null
+++ b/src/util/deep-equal.js
@@ -0,0 +1,57 @@
+/**
+ * Verify that an object is only an object and not null.
+ *
+ * @param {Object} obj
+ * The obj to check
+ *
+ * @return {boolean}
+ * If the objects is actually an object and not null.
+ */
+const isObject = (obj) =>
+ !!obj && typeof obj === 'object';
+
+/**
+ * A function to check if two objects are equal
+ * to any depth.
+ *
+ * @param {Object} a
+ * The first object.
+ *
+ * @param {Object} b
+ * The second object.
+ *
+ * @return {boolean}
+ * If the objects are equal or not.
+ */
+const deepEqual = function(a, b) {
+ // equal
+ if (a === b) {
+ return true;
+ }
+
+ // if one or the other is not an object and they
+ // are not equal (as checked above) then they are not
+ // deepEqual
+ if (!isObject(a) || !isObject(b)) {
+ return false;
+ }
+
+ const aKeys = Object.keys(a);
+
+ // they have different number of keys
+ if (aKeys.length !== Object.keys(b).length) {
+ return false;
+ }
+
+ for (let i = 0; i < aKeys.length; i++) {
+ const key = aKeys[i];
+
+ if (!deepEqual(a[key], b[key])) {
+ return false;
+ }
+ }
+
+ return true;
+};
+
+export default deepEqual;
diff --git a/test/playlist-loader/dash-main-playlist-loader.test.js b/test/playlist-loader/dash-main-playlist-loader.test.js
new file mode 100644
index 000000000..f932ad9b9
--- /dev/null
+++ b/test/playlist-loader/dash-main-playlist-loader.test.js
@@ -0,0 +1,250 @@
+import QUnit from 'qunit';
+import videojs from 'video.js';
+import DashMainPlaylistLoader from '../../src/playlist-loader/dash-main-playlist-loader.js';
+import {useFakeEnvironment} from '../test-helpers';
+import xhrFactory from '../../src/xhr';
+import testDataManifests from 'create-test-data!manifests';
+
+QUnit.module('Dash Main Playlist Loader', function(hooks) {
+ hooks.beforeEach(function(assert) {
+ this.env = useFakeEnvironment(assert);
+ this.clock = this.env.clock;
+ this.requests = this.env.requests;
+ this.fakeVhs = {
+ xhr: xhrFactory()
+ };
+ this.logLines = [];
+ this.oldDebugLog = videojs.log.debug;
+ videojs.log.debug = (...args) => {
+ this.logLines.push(args.join(' '));
+ };
+ });
+
+ hooks.afterEach(function(assert) {
+ if (this.loader) {
+ this.loader.dispose();
+ }
+ this.env.restore();
+ videojs.log.debug = this.oldDebugLog;
+ });
+
+ // Since playlists is mostly a wrapper around forEachPlaylists
+ // most of the tests are located there.
+ QUnit.module('#playlists()');
+
+ QUnit.test('returns empty array without playlists', function(assert) {
+ this.loader = new DashMainPlaylistLoader('dash-many-codecs.mpd', {
+ vhs: this.fakeVhs
+ });
+
+ assert.deepEqual(this.loader.playlists(), [], 'no playlists');
+ });
+
+ QUnit.module('#setMediaRefreshTime_()/#getMediaRefreshTime_()');
+
+ QUnit.test('used when minimumUpdatePeriod is zero', function(assert) {
+ this.loader = new DashMainPlaylistLoader('dash-many-codecs.mpd', {
+ vhs: this.fakeVhs
+ });
+
+ this.loader.manifest_ = {
+ minimumUpdatePeriod: 0
+ };
+
+ this.loader.setMediaRefreshTimeout_ = () => {};
+ this.loader.setMediaRefreshTime_(200);
+
+ assert.equal(this.loader.getMediaRefreshTime_(), 200, 'as expected');
+ });
+
+ QUnit.test('ignored when minimumUpdatePeriod is set', function(assert) {
+ this.loader = new DashMainPlaylistLoader('dash-many-codecs.mpd', {
+ vhs: this.fakeVhs
+ });
+
+ this.loader.manifest_ = {
+ minimumUpdatePeriod: 5
+ };
+
+ this.loader.setMediaRefreshTimeout_ = () => {};
+ this.loader.setMediaRefreshTime_(200);
+
+ assert.equal(this.loader.getMediaRefreshTime_(), 5, 'as expected');
+ });
+
+ QUnit.test('ignored when minimumUpdatePeriod invalid', function(assert) {
+ this.loader = new DashMainPlaylistLoader('dash-many-codecs.mpd', {
+ vhs: this.fakeVhs
+ });
+
+ this.loader.manifest_ = {
+ minimumUpdatePeriod: -1
+ };
+
+ this.loader.setMediaRefreshTimeout_ = () => {};
+ this.loader.setMediaRefreshTime_(200);
+
+ assert.equal(this.loader.getMediaRefreshTime_(), null, 'as expected');
+ });
+
+ QUnit.module('#parseManifest_()');
+
+ QUnit.test('parses given manifest', function(assert) {
+ this.loader = new DashMainPlaylistLoader('dash-many-codecs.mpd', {
+ vhs: this.fakeVhs
+ });
+
+ this.loader.parseManifest_(testDataManifests['dash-many-codecs'], function(manifest, updated) {
+ assert.ok(manifest, 'manifest is valid');
+ assert.true(updated, 'updated is always true');
+ });
+ });
+
+ QUnit.test('merges manifests, but only uses new manifest playlists', function(assert) {
+ this.loader = new DashMainPlaylistLoader('dash-many-codecs.mpd', {
+ vhs: this.fakeVhs
+ });
+ let oldManifest;
+
+ this.loader.parseManifest_(testDataManifests['dash-many-codecs'], (manifest, updated) => {
+ this.loader.manifest_ = manifest;
+ oldManifest = manifest;
+ });
+
+ this.loader.parseManifest_(testDataManifests['dash-many-codecs'], (manifest, updated) => {
+ assert.notEqual(manifest.playlists, oldManifest.playlists, 'playlists not merged');
+ });
+ });
+
+ QUnit.test('calls syncClientServerClock_()', function(assert) {
+ this.loader = new DashMainPlaylistLoader('dash-many-codecs.mpd', {
+ vhs: this.fakeVhs
+ });
+ let called = false;
+
+ this.loader.syncClientServerClock_ = () => {
+ called = true;
+ };
+
+ this.loader.parseManifest_(testDataManifests['dash-many-codecs'], () => {});
+
+ assert.true(called, 'syncClientServerClock_ called');
+ });
+
+ QUnit.module('syncClientServerClock_', {
+ beforeEach() {
+ this.loader = new DashMainPlaylistLoader('dash-many-codecs.mpd', {
+ vhs: this.fakeVhs
+ });
+
+ this.loader.started_ = true;
+ }
+ });
+
+ QUnit.test('without utc timing returns a default', function(assert) {
+ const manifestString = '';
+
+ this.loader.lastRequestTime = () => 100;
+ this.clock.tick(50);
+
+ this.loader.syncClientServerClock_(manifestString, function(value) {
+ assert.equal(value, 50, 'as expected');
+ });
+ });
+
+ QUnit.test('can use HEAD', function(assert) {
+ const manifestString =
+ '' +
+ '' +
+ '' +
+ '';
+
+ this.loader.syncClientServerClock_(manifestString, function(value) {
+ assert.equal(value, 20000, 'client server clock is 20s (20000ms)');
+ });
+
+ assert.equal(this.requests.length, 1, 'has one sync request');
+ assert.equal(this.requests[0].method, 'HEAD', 'head request');
+
+ const date = new Date();
+
+ date.setSeconds(date.getSeconds() + 20);
+
+ this.requests[0].respond(200, {date: date.toString()});
+ });
+
+ QUnit.test('can use invalid HEAD', function(assert) {
+ const manifestString =
+ '' +
+ '' +
+ '' +
+ '';
+
+ this.loader.lastRequestTime = () => 55;
+ this.loader.syncClientServerClock_(manifestString, function(value) {
+ assert.equal(value, 55, 'is lastRequestTime');
+ });
+
+ assert.equal(this.requests.length, 1, 'has one sync request');
+ assert.equal(this.requests[0].method, 'HEAD', 'head request');
+
+ this.requests[0].respond(200);
+ });
+
+ QUnit.test('can use GET', function(assert) {
+ const manifestString =
+ '' +
+ '' +
+ '' +
+ '';
+
+ this.loader.syncClientServerClock_(manifestString, function(value) {
+ assert.equal(value, 20000, 'client server clock is 20s (20000ms)');
+ });
+
+ assert.equal(this.requests.length, 1, 'has one sync request');
+ assert.equal(this.requests[0].method, 'GET', 'GET request');
+
+ const date = new Date();
+
+ date.setSeconds(date.getSeconds() + 20);
+
+ this.requests[0].respond(200, null, date.toString());
+ });
+
+ QUnit.test('can use DIRECT', function(assert) {
+ const date = new Date();
+
+ date.setSeconds(date.getSeconds() + 20);
+
+ const manifestString =
+ '' +
+ '' +
+ '' +
+ '';
+
+ this.loader.syncClientServerClock_(manifestString, function(value) {
+ assert.equal(value, 20000, 'client server clock is 20s (20000ms)');
+ });
+ });
+
+ QUnit.test('uses lastRequestTime on request failure', function(assert) {
+ const manifestString =
+ '' +
+ '' +
+ '' +
+ '';
+
+ this.loader.lastRequestTime = () => 100;
+ this.clock.tick(50);
+
+ this.loader.syncClientServerClock_(manifestString, function(value) {
+ assert.equal(value, 50, 'as expected');
+ });
+
+ assert.equal(this.requests.length, 1, 'has one sync request');
+ assert.equal(this.requests[0].method, 'HEAD', 'head request');
+
+ this.requests[0].respond(404);
+ });
+});
diff --git a/test/playlist-loader/dash-media-playlist-loader.test.js b/test/playlist-loader/dash-media-playlist-loader.test.js
new file mode 100644
index 000000000..97ad911c4
--- /dev/null
+++ b/test/playlist-loader/dash-media-playlist-loader.test.js
@@ -0,0 +1,514 @@
+import QUnit from 'qunit';
+import videojs from 'video.js';
+import DashMainPlaylistLoader from '../../src/playlist-loader/dash-main-playlist-loader.js';
+import DashMediaPlaylistLoader from '../../src/playlist-loader/dash-media-playlist-loader.js';
+import {useFakeEnvironment, standardXHRResponse} from '../test-helpers';
+import xhrFactory from '../../src/xhr';
+import testDataManifests from 'create-test-data!manifests';
+import {
+ sidx as sidxResponse,
+ mp4VideoInit as mp4VideoInitSegment,
+ webmVideoInit
+} from 'create-test-data!segments';
+
+QUnit.module('Dash Media Playlist Loader', function(hooks) {
+ hooks.beforeEach(function(assert) {
+ this.env = useFakeEnvironment(assert);
+ this.clock = this.env.clock;
+ this.requests = this.env.requests;
+ this.fakeVhs = {
+ xhr: xhrFactory()
+ };
+ this.logLines = [];
+ this.oldDebugLog = videojs.log.debug;
+ videojs.log.debug = (...args) => {
+ this.logLines.push(args.join(' '));
+ };
+
+ this.mainPlaylistLoader = new DashMainPlaylistLoader('main-manifests.mpd', {
+ vhs: this.fakeVhs
+ });
+
+ this.setMediaRefreshTimeCalls = [];
+
+ this.mainPlaylistLoader.setMediaRefreshTime_ = (time) => {
+ this.setMediaRefreshTimeCalls.push(time);
+ };
+
+ this.mainPlaylistLoader.start();
+ });
+
+ hooks.afterEach(function(assert) {
+ if (this.mainPlaylistLoader) {
+ this.mainPlaylistLoader.dispose();
+ }
+ if (this.loader) {
+ this.loader.dispose();
+ }
+ this.env.restore();
+ videojs.log.debug = this.oldDebugLog;
+ });
+
+ QUnit.module('#start()', {
+ beforeEach() {
+ this.requests.shift().respond(200, null, testDataManifests['dash-many-codecs']);
+ }
+ });
+
+ QUnit.test('multiple calls do nothing', function(assert) {
+ this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.playlists()[0].uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+
+ let onMainUpdatedCalls = 0;
+
+ this.loader.onMainUpdated_ = () => {
+ onMainUpdatedCalls++;
+ };
+
+ this.loader.start();
+ assert.equal(onMainUpdatedCalls, 1, 'one on main updated call');
+ assert.true(this.loader.started_, 'started');
+
+ this.loader.start();
+ assert.equal(onMainUpdatedCalls, 1, 'still one on main updated call');
+ assert.true(this.loader.started_, 'still started');
+ });
+
+ QUnit.module('#stop()', {
+ beforeEach() {
+ this.requests.shift().respond(200, null, testDataManifests['dash-many-codecs']);
+ }
+ });
+
+ QUnit.test('multiple calls do nothing', function(assert) {
+ this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.playlists()[0].uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+
+ this.loader.manifest_ = {};
+ this.loader.started_ = true;
+ this.loader.stop();
+
+ assert.equal(this.loader.manifest_, null, 'manifest cleared');
+ assert.false(this.loader.started_, 'stopped');
+ assert.deepEqual(
+ this.setMediaRefreshTimeCalls,
+ [null],
+ 'setMediaRefreshTime called with null on mainPlaylistLoader'
+ );
+
+ this.loader.manifest_ = {};
+ this.loader.stop();
+
+ assert.deepEqual(this.loader.manifest_, {}, 'manifest not cleared');
+ assert.false(this.loader.started_, 'still stopped');
+ });
+
+ QUnit.module('#onMainUpdated_()', {
+ beforeEach() {
+ this.requests.shift().respond(200, null, testDataManifests['dash-many-codecs']);
+ }
+ });
+
+ QUnit.test('triggers error if not found', function(assert) {
+ this.loader = new DashMediaPlaylistLoader('non-existant.uri', {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+ let errorTriggered = false;
+
+ this.loader.started_ = true;
+ this.loader.on('error', function() {
+ errorTriggered = true;
+ });
+
+ this.loader.onMainUpdated_();
+ assert.true(errorTriggered, 'error was triggered');
+ });
+
+ QUnit.test('called via updated event on mainPlaylistLoader', function(assert) {
+ this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.playlists()[0].uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+
+ let onMainUpdatedCalls = 0;
+
+ this.loader.onMainUpdated_ = () => {
+ onMainUpdatedCalls++;
+ };
+
+ this.mainPlaylistLoader.trigger('updated');
+
+ assert.equal(onMainUpdatedCalls, 1, 'called on main updated');
+ });
+
+ QUnit.test('does nothing if not started', function(assert) {
+ this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.playlists()[0].uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+
+ this.loader.onMainUpdated_();
+
+ assert.equal(this.loader.manifest_, null, 'still no manifest');
+ });
+
+ QUnit.test('triggers updated without oldManifest', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+
+ let updatedTriggered = false;
+
+ this.loader.on('updated', function() {
+ updatedTriggered = true;
+ });
+
+ this.loader.started_ = true;
+ this.loader.onMainUpdated_();
+ assert.equal(this.loader.manifest(), this.mainPlaylistLoader.playlists()[0], 'manifest set as expected');
+ assert.true(updatedTriggered, 'updatedTriggered');
+ assert.deepEqual(
+ this.setMediaRefreshTimeCalls,
+ [media.targetDuration * 1000],
+ 'setMediaRefreshTime called on mainPlaylistLoader'
+ );
+ });
+
+ QUnit.test('does not trigger updated if manifest is the same', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+
+ let updatedTriggered = false;
+
+ this.loader.on('updated', function() {
+ updatedTriggered = true;
+ });
+
+ this.loader.manifest_ = media;
+ this.loader.started_ = true;
+ this.loader.onMainUpdated_();
+
+ assert.equal(this.loader.manifest(), this.mainPlaylistLoader.playlists()[0], 'manifest set as expected');
+ assert.false(updatedTriggered, 'updatedTriggered');
+ assert.deepEqual(
+ this.setMediaRefreshTimeCalls,
+ [],
+ 'no set media refresh calls'
+ );
+ });
+
+ QUnit.test('triggers updated if manifest properties changed', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+
+ let updatedTriggered = false;
+
+ this.loader.on('updated', function() {
+ updatedTriggered = true;
+ });
+
+ this.loader.manifest_ = Object.assign({}, media);
+ this.loader.started_ = true;
+ media.targetDuration = 5;
+
+ this.loader.onMainUpdated_();
+
+ assert.equal(this.loader.manifest(), this.mainPlaylistLoader.playlists()[0], 'manifest set as expected');
+ assert.true(updatedTriggered, 'updatedTriggered');
+ assert.deepEqual(
+ this.setMediaRefreshTimeCalls,
+ [5000],
+ 'no set media refresh calls'
+ );
+ });
+
+ QUnit.test('triggers updated if segment properties changed', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+
+ let updatedTriggered = false;
+
+ this.loader.on('updated', function() {
+ updatedTriggered = true;
+ });
+
+ // clone proprety that we are going to change
+ this.loader.manifest_ = Object.assign({}, media);
+ this.loader.manifest_.segments = media.segments.slice();
+ this.loader.manifest_.segments[0] = Object.assign({}, media.segments[0]);
+ this.loader.manifest_.segments[0].map = Object.assign({}, media.segments[0].map);
+ this.loader.started_ = true;
+
+ media.segments[0].map.foo = 'bar';
+
+ this.loader.onMainUpdated_();
+
+ assert.equal(this.loader.manifest(), this.mainPlaylistLoader.playlists()[0], 'manifest set as expected');
+ assert.true(updatedTriggered, 'updatedTriggered');
+ assert.deepEqual(
+ this.setMediaRefreshTimeCalls,
+ [4000],
+ 'no set media refresh calls'
+ );
+ });
+
+ QUnit.test('calls requestSidx_', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+
+ let requestSidxCalled = false;
+
+ this.loader.requestSidx_ = (callback) => {
+ requestSidxCalled = true;
+ };
+
+ this.loader.manifest_ = Object.assign({}, media);
+ this.loader.started_ = true;
+ media.targetDuration = 5;
+
+ this.loader.onMainUpdated_();
+
+ assert.true(requestSidxCalled, 'requestSidx_ was called');
+ });
+
+ QUnit.module('#requestSidx_()', {
+ beforeEach() {
+ this.requests.shift().respond(200, null, testDataManifests['dash-sidx']);
+ }
+ });
+
+ QUnit.test('does nothing if manifest has no sidx', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ delete media.sidx;
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+ this.loader.started_ = true;
+
+ this.loader.onMainUpdated_();
+
+ assert.equal(this.loader.manifest().segments.length, 0, 'no segments');
+ assert.equal(this.requests.length, 0, 'no sidx request');
+ });
+
+ QUnit.test('requests container then sidx bytes', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+ this.loader.started_ = true;
+
+ this.loader.onMainUpdated_();
+
+ assert.equal(this.loader.manifest().segments.length, 0, 'no segments');
+ assert.equal(this.requests.length, 1, 'one request for container');
+ assert.equal(this.loader.request(), this.requests[0], 'loader has a request');
+
+ standardXHRResponse(this.requests.shift(), mp4VideoInitSegment().subarray(0, 10));
+
+ assert.equal(this.requests.length, 1, 'one request for sidx bytes');
+ assert.equal(this.loader.request(), this.requests[0], 'loader has a request');
+ standardXHRResponse(this.requests.shift(), sidxResponse());
+
+ assert.equal(this.loader.manifest().segments.length, 1, 'sidx segment added');
+ });
+
+ QUnit.test('can use sidx from container request', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+ this.loader.started_ = true;
+
+ this.loader.onMainUpdated_();
+
+ assert.equal(this.loader.manifest().segments.length, 0, 'no segments');
+ assert.equal(this.requests.length, 1, 'one request for container');
+ assert.equal(this.loader.request(), this.requests[0], 'loader has a request');
+
+ const sidxByterange = this.loader.manifest_.sidx.byterange;
+ // container bytes + length + offset
+ const response = new Uint8Array(10 + sidxByterange.length + sidxByterange.offset);
+
+ response.set(mp4VideoInitSegment().subarray(0, 10), 0);
+ response.set(sidxResponse(), sidxByterange.offset);
+
+ standardXHRResponse(this.requests.shift(), response);
+
+ assert.equal(this.requests.length, 0, 'no more requests ');
+ assert.equal(this.loader.manifest().segments.length, 1, 'sidx segment added');
+ assert.equal(this.loader.request(), null, 'loader has no request');
+ });
+
+ QUnit.test('container request failure reported', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+ this.loader.started_ = true;
+
+ let errorTriggered = false;
+
+ this.loader.on('error', function() {
+ errorTriggered = true;
+ });
+ this.loader.onMainUpdated_();
+
+ assert.equal(this.loader.manifest().segments.length, 0, 'no segments');
+ assert.equal(this.requests.length, 1, 'one request for container');
+ assert.equal(this.loader.request(), this.requests[0], 'loader has a request');
+
+ this.requests.shift().respond(404);
+ assert.true(errorTriggered, 'error triggered');
+ assert.equal(this.loader.request(), null, 'loader has no request');
+ });
+
+ QUnit.test('undefined container errors', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+ this.loader.started_ = true;
+
+ let errorTriggered = false;
+
+ this.loader.on('error', function() {
+ errorTriggered = true;
+ });
+ this.loader.onMainUpdated_();
+
+ assert.equal(this.loader.manifest().segments.length, 0, 'no segments');
+ assert.equal(this.requests.length, 1, 'one request for container');
+ assert.equal(this.loader.request(), this.requests[0], 'loader has a request');
+
+ standardXHRResponse(this.requests.shift(), new Uint8Array(200));
+ assert.true(errorTriggered, 'error triggered');
+ assert.equal(this.loader.request(), null, 'loader has no request');
+ });
+
+ QUnit.test('webm container errors', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+ this.loader.started_ = true;
+
+ let errorTriggered = false;
+
+ this.loader.on('error', function() {
+ errorTriggered = true;
+ });
+ this.loader.onMainUpdated_();
+
+ assert.equal(this.loader.manifest().segments.length, 0, 'no segments');
+ assert.equal(this.requests.length, 1, 'one request for container');
+ assert.equal(this.loader.request(), this.requests[0], 'loader has a request');
+
+ standardXHRResponse(this.requests.shift(), webmVideoInit());
+ assert.true(errorTriggered, 'error triggered');
+ assert.equal(this.loader.request(), null, 'loader has no request');
+ });
+
+ QUnit.test('sidx request failure reported', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+ this.loader.started_ = true;
+
+ let errorTriggered = false;
+
+ this.loader.on('error', function() {
+ errorTriggered = true;
+ });
+ this.loader.onMainUpdated_();
+
+ assert.equal(this.loader.manifest().segments.length, 0, 'no segments');
+ assert.equal(this.requests.length, 1, 'one request for container');
+ assert.equal(this.loader.request(), this.requests[0], 'loader has a request');
+
+ standardXHRResponse(this.requests.shift(), mp4VideoInitSegment().subarray(0, 10));
+
+ assert.equal(this.requests.length, 1, 'one request for container');
+ assert.equal(this.loader.request(), this.requests[0], 'loader has a request');
+ assert.false(errorTriggered, 'error not triggered');
+
+ this.requests.shift().respond(404);
+
+ assert.true(errorTriggered, 'error triggered');
+ assert.equal(this.loader.request(), null, 'loader has no request');
+ });
+
+ QUnit.test('sidx parse failure reported', function(assert) {
+ const media = this.mainPlaylistLoader.playlists()[0];
+
+ this.loader = new DashMediaPlaylistLoader(media.uri, {
+ vhs: this.fakeVhs,
+ mainPlaylistLoader: this.mainPlaylistLoader
+ });
+ this.loader.started_ = true;
+
+ let errorTriggered = false;
+
+ this.loader.on('error', function() {
+ errorTriggered = true;
+ });
+ this.loader.onMainUpdated_();
+
+ assert.equal(this.loader.manifest().segments.length, 0, 'no segments');
+ assert.equal(this.requests.length, 1, 'one request for container');
+ assert.equal(this.loader.request(), this.requests[0], 'loader has a request');
+
+ standardXHRResponse(this.requests.shift(), mp4VideoInitSegment().subarray(0, 10));
+
+ assert.equal(this.requests.length, 1, 'one request for container');
+ assert.equal(this.loader.request(), this.requests[0], 'loader has a request');
+ assert.false(errorTriggered, 'error not triggered');
+
+ standardXHRResponse(this.requests.shift(), new Uint8Array(10));
+
+ assert.true(errorTriggered, 'error triggered');
+ assert.equal(this.loader.request(), null, 'loader has no request');
+ });
+
+});
+
diff --git a/test/playlist-loader/hls-main-playlist-loader.test.js b/test/playlist-loader/hls-main-playlist-loader.test.js
new file mode 100644
index 000000000..b1a4ead78
--- /dev/null
+++ b/test/playlist-loader/hls-main-playlist-loader.test.js
@@ -0,0 +1,155 @@
+import QUnit from 'qunit';
+import videojs from 'video.js';
+import HlsMainPlaylistLoader from '../../src/playlist-loader/hls-main-playlist-loader.js';
+import {useFakeEnvironment} from '../test-helpers';
+import xhrFactory from '../../src/xhr';
+import testDataManifests from 'create-test-data!manifests';
+
+QUnit.module('HLS Main Playlist Loader', function(hooks) {
+ hooks.beforeEach(function(assert) {
+ this.env = useFakeEnvironment(assert);
+ this.clock = this.env.clock;
+ this.requests = this.env.requests;
+ this.fakeVhs = {
+ xhr: xhrFactory()
+ };
+ this.logLines = [];
+ this.oldDebugLog = videojs.log.debug;
+ videojs.log.debug = (...args) => {
+ this.logLines.push(args.join(' '));
+ };
+ });
+ hooks.afterEach(function(assert) {
+ if (this.loader) {
+ this.loader.dispose();
+ }
+ this.env.restore();
+ videojs.log.debug = this.oldDebugLog;
+ });
+
+ QUnit.module('#start()');
+
+ QUnit.test('requests and parses a manifest', function(assert) {
+ assert.expect(8);
+ this.loader = new HlsMainPlaylistLoader('master.m3u8', {
+ vhs: this.fakeVhs
+ });
+
+ let updatedTriggered = false;
+
+ this.loader.on('updated', function() {
+ updatedTriggered = true;
+ });
+ this.loader.start();
+
+ assert.true(this.loader.started_, 'was started');
+ assert.ok(this.loader.request_, 'has request');
+
+ this.requests[0].respond(200, null, testDataManifests.master);
+
+ assert.equal(this.loader.request_, null, 'request is done');
+ assert.ok(this.loader.manifest(), 'manifest was set');
+ assert.equal(this.loader.manifestString_, testDataManifests.master, 'manifest string set');
+ assert.true(updatedTriggered, 'updated was triggered');
+ });
+
+ QUnit.test('does not re-request a manifest if it has one.', function(assert) {
+ assert.expect(4);
+ this.loader = new HlsMainPlaylistLoader('master.m3u8', {
+ vhs: this.fakeVhs
+ });
+
+ this.loader.manifest_ = {};
+ this.loader.start();
+
+ assert.true(this.loader.started_, 'was started');
+ assert.equal(this.loader.request_, null, 'has no request');
+ });
+
+ QUnit.test('forced manifest refresh is not updated with the same response', function(assert) {
+ assert.expect(11);
+ this.loader = new HlsMainPlaylistLoader('master.m3u8', {
+ vhs: this.fakeVhs
+ });
+ let updatedTriggers = 0;
+
+ this.loader.on('updated', function() {
+ updatedTriggers++;
+ });
+ this.loader.start();
+
+ assert.true(this.loader.started_, 'was started');
+ assert.ok(this.loader.request_, 'has request');
+
+ this.requests[0].respond(200, null, testDataManifests.master);
+
+ assert.equal(this.loader.request_, null, 'request is done');
+ assert.ok(this.loader.manifest(), 'manifest was set');
+ assert.equal(this.loader.manifestString_, testDataManifests.master, 'manifest string set');
+ assert.equal(updatedTriggers, 1, 'one updated trigger');
+
+ this.loader.refreshManifest_();
+ assert.ok(this.loader.request_, 'has request');
+ this.requests[1].respond(200, null, testDataManifests.master);
+
+ assert.equal(this.loader.request_, null, 'request is done');
+ assert.equal(updatedTriggers, 1, 'not updated again');
+ });
+
+ QUnit.test('forced manifest refresh is updated with new response', function(assert) {
+ assert.expect(13);
+ this.loader = new HlsMainPlaylistLoader('master.m3u8', {
+ vhs: this.fakeVhs
+ });
+ let updatedTriggers = 0;
+
+ this.loader.on('updated', function() {
+ updatedTriggers++;
+ });
+ this.loader.start();
+
+ assert.true(this.loader.started_, 'was started');
+ assert.ok(this.loader.request_, 'has request');
+
+ this.requests[0].respond(200, null, testDataManifests.master);
+
+ assert.equal(this.loader.request_, null, 'request is done');
+ assert.ok(this.loader.manifest(), 'manifest was set');
+ assert.equal(this.loader.manifestString_, testDataManifests.master, 'manifest string set');
+ assert.equal(updatedTriggers, 1, 'one updated trigger');
+
+ this.loader.refreshManifest_();
+ assert.ok(this.loader.request_, 'has request');
+ this.requests[1].respond(200, null, testDataManifests['master-captions']);
+
+ assert.equal(this.loader.request_, null, 'request is done');
+ assert.ok(this.loader.manifest(), 'manifest was set');
+ assert.equal(this.loader.manifestString_, testDataManifests['master-captions'], 'manifest string set');
+ assert.equal(updatedTriggers, 2, 'updated again');
+ });
+
+ QUnit.test('can handle media playlist passed as main', function(assert) {
+ assert.expect(8);
+ this.loader = new HlsMainPlaylistLoader('master.m3u8', {
+ vhs: this.fakeVhs
+ });
+
+ let updatedTriggered = false;
+
+ this.loader.on('updated', function() {
+ updatedTriggered = true;
+ });
+ this.loader.start();
+
+ assert.true(this.loader.started_, 'was started');
+ assert.ok(this.loader.request_, 'has request');
+
+ this.requests[0].respond(200, null, testDataManifests.media);
+
+ assert.equal(this.loader.request_, null, 'request is done');
+ assert.ok(this.loader.manifest(), 'manifest was set');
+ assert.equal(this.loader.manifestString_, testDataManifests.media, 'manifest string set');
+ assert.true(updatedTriggered, 'updated was triggered');
+ });
+});
+
diff --git a/test/playlist-loader/hls-media-playlist-loader.test.js b/test/playlist-loader/hls-media-playlist-loader.test.js
new file mode 100644
index 000000000..7810959f1
--- /dev/null
+++ b/test/playlist-loader/hls-media-playlist-loader.test.js
@@ -0,0 +1,261 @@
+import QUnit from 'qunit';
+import videojs from 'video.js';
+import {
+ default as HlsMediaPlaylistLoader,
+ getAllSegments,
+ timeBeforeRefresh
+} from '../../src/playlist-loader/hls-media-playlist-loader.js';
+import {useFakeEnvironment} from '../test-helpers';
+import xhrFactory from '../../src/xhr';
+import testDataManifests from 'create-test-data!manifests';
+
+QUnit.module('HLS Media Playlist Loader', function(hooks) {
+ hooks.beforeEach(function(assert) {
+ this.env = useFakeEnvironment(assert);
+ this.clock = this.env.clock;
+ this.requests = this.env.requests;
+ this.fakeVhs = {
+ xhr: xhrFactory()
+ };
+ this.logLines = [];
+ this.oldDebugLog = videojs.log.debug;
+ videojs.log.debug = (...args) => {
+ this.logLines.push(args.join(' '));
+ };
+ });
+ hooks.afterEach(function(assert) {
+ if (this.loader) {
+ this.loader.dispose();
+ }
+ this.env.restore();
+ videojs.log.debug = this.oldDebugLog;
+ });
+
+ QUnit.module('#start()');
+
+ QUnit.test('requests and parses a manifest', function(assert) {
+ assert.expect(8);
+ this.loader = new HlsMediaPlaylistLoader('media.m3u8', {
+ vhs: this.fakeVhs
+ });
+
+ let updatedTriggered = false;
+
+ this.loader.on('updated', function() {
+ updatedTriggered = true;
+ });
+ this.loader.start();
+
+ assert.true(this.loader.started_, 'was started');
+ assert.ok(this.loader.request_, 'has request');
+
+ this.requests[0].respond(200, null, testDataManifests.media);
+
+ assert.equal(this.loader.request_, null, 'request is done');
+ assert.ok(this.loader.manifest(), 'manifest was set');
+ assert.equal(this.loader.manifestString_, testDataManifests.media, 'manifest string set');
+ assert.true(updatedTriggered, 'updated was triggered');
+ });
+
+ QUnit.test('does not re-request when we have a vod manifest already', function(assert) {
+ assert.expect(5);
+ this.loader = new HlsMediaPlaylistLoader('media.m3u8', {
+ vhs: this.fakeVhs
+ });
+
+ let updatedTriggered = false;
+
+ this.loader.manifest = () => {
+ return {endList: true};
+ };
+
+ this.loader.on('updated', function() {
+ updatedTriggered = true;
+ });
+ this.loader.start();
+
+ assert.true(this.loader.started_, 'was started');
+ assert.equal(this.loader.request_, null, 'no request');
+ assert.false(updatedTriggered, 'updated was not triggered');
+ });
+
+ QUnit.module('#parseManifest_()');
+
+ QUnit.test('works as expected', function(assert) {
+ assert.expect(8);
+ this.loader = new HlsMediaPlaylistLoader('media.m3u8', {
+ vhs: this.fakeVhs
+ });
+ const media =
+ '#EXTM3U\n' +
+ '#EXT-X-MEDIA-SEQUENCE:0\n' +
+ '#EXTINF:10\n' +
+ '0.ts\n' +
+ '#EXTINF:10\n' +
+ '1.ts\n';
+
+ // first media
+ this.loader.parseManifest_(media, (mergedMedia, updated) => {
+ assert.ok(mergedMedia, 'media returned');
+ assert.true(updated, 'was updated');
+ this.loader.manifest_ = mergedMedia;
+ this.loader.manifestString_ = testDataManifests.media;
+ });
+
+ // same media
+ this.loader.parseManifest_(media, (mergedMedia, updated) => {
+ assert.ok(mergedMedia, 'media returned');
+ assert.false(updated, 'was not updated');
+ });
+
+ const mediaUpdate = media +
+ '#EXTINF:10\n' +
+ '2.ts\n';
+
+ // media updated
+ this.loader.parseManifest_(mediaUpdate, (mergedMedia, updated) => {
+ assert.ok(mergedMedia, 'media returned');
+ assert.true(updated, 'was updated for media update');
+ });
+ });
+
+ QUnit.module('timeBeforeRefresh');
+
+ QUnit.test('defaults to 5000ms without target duration or segments', function(assert) {
+ const manifest = {};
+
+ assert.equal(timeBeforeRefresh(manifest), 5000, 'as expected');
+ assert.equal(timeBeforeRefresh(manifest, true), 5000, 'as expected');
+ });
+
+ QUnit.test('uses last segment duration when update is true', function(assert) {
+ const manifest = {targetDuration: 5, segments: [
+ {duration: 4.9},
+ {duration: 5.1}
+ ]};
+
+ assert.equal(timeBeforeRefresh(manifest, true), 5100, 'as expected');
+ });
+
+ QUnit.test('uses last part duration if it exists when update is true', function(assert) {
+ const manifest = {targetDuration: 5, segments: [
+ {duration: 4.9},
+ {duration: 5.1, parts: [
+ {duration: 0.9},
+ {duration: 1.1},
+ {duration: 0.8},
+ {duration: 1.2},
+ {duration: 1}
+ ]}
+ ]};
+
+ assert.equal(timeBeforeRefresh(manifest, true), 1000, 'as expected');
+ });
+
+ QUnit.test('uses half of target duration without updated', function(assert) {
+ const manifest = {targetDuration: 5, segments: [
+ {duration: 4.9},
+ {duration: 5.1, parts: [
+ {duration: 0.9},
+ {duration: 1.1},
+ {duration: 0.8},
+ {duration: 1.2},
+ {duration: 1}
+ ]}
+ ]};
+
+ assert.equal(timeBeforeRefresh(manifest), 2500, 'as expected');
+ });
+
+ QUnit.test('uses half of part target duration without updated', function(assert) {
+ const manifest = {partTargetDuration: 1, targetDuration: 5, segments: [
+ {duration: 4.9},
+ {duration: 5.1, parts: [
+ {duration: 0.9},
+ {duration: 1.1},
+ {duration: 0.8},
+ {duration: 1.2},
+ {duration: 1}
+ ]}
+ ]};
+
+ assert.equal(timeBeforeRefresh(manifest), 500, 'as expected');
+ });
+
+ QUnit.module('getAllSegments');
+
+ QUnit.test('handles preloadSegments', function(assert) {
+ const manifest = {
+ targetDuration: 5,
+ segments: [{duration: 5}],
+ preloadSegment: {
+ parts: [{duration: 1}]
+ }
+ };
+
+ assert.deepEqual(
+ getAllSegments(manifest),
+ [{duration: 5}, {duration: 5, preload: true, parts: [{duration: 1}]}],
+ 'has one segment from preloadSegment',
+ );
+ });
+
+ QUnit.test('handles preloadSegments with PART preloadHints', function(assert) {
+ const manifest = {
+ targetDuration: 5,
+ segments: [{duration: 5}],
+ preloadSegment: {
+ parts: [{duration: 1}],
+ preloadHints: [{type: 'PART'}]
+ }
+ };
+
+ assert.deepEqual(
+ getAllSegments(manifest),
+ [
+ {duration: 5},
+ {duration: 5, preload: true, parts: [{duration: 1}], preloadHints: [{type: 'PART'}]}
+ ],
+ 'has one segment from preloadSegment',
+ );
+ });
+
+ QUnit.test('skips preloadSegments with MAP preloadHints', function(assert) {
+ const manifest = {
+ targetDuration: 5,
+ segments: [{duration: 5}],
+ preloadSegment: {
+ parts: [{duration: 1}],
+ preloadHints: [{type: 'MAP'}]
+ }
+ };
+
+ assert.deepEqual(
+ getAllSegments(manifest),
+ [{duration: 5}],
+ 'has nothing',
+ );
+ });
+
+ QUnit.test('adds skip segments before all others', function(assert) {
+ const manifest = {
+ targetDuration: 5,
+ segments: [{duration: 5}],
+ preloadSegment: {parts: [{duration: 1}]},
+ skip: {skippedSegments: 2}
+ };
+
+ assert.deepEqual(
+ getAllSegments(manifest),
+ [
+ {skipped: true},
+ {skipped: true},
+ {duration: 5},
+ {duration: 5, preload: true, parts: [{duration: 1}]}
+ ],
+ 'has nothing',
+ );
+ });
+
+});
+
diff --git a/test/playlist-loader/playlist-loader.test.js b/test/playlist-loader/playlist-loader.test.js
new file mode 100644
index 000000000..896f07776
--- /dev/null
+++ b/test/playlist-loader/playlist-loader.test.js
@@ -0,0 +1,668 @@
+import QUnit from 'qunit';
+import videojs from 'video.js';
+import PlaylistLoader from '../../src/playlist-loader/playlist-loader.js';
+import {useFakeEnvironment, urlTo} from '../test-helpers';
+import xhrFactory from '../../src/xhr';
+
+QUnit.module('New Playlist Loader', function(hooks) {
+ hooks.beforeEach(function(assert) {
+ this.env = useFakeEnvironment(assert);
+ this.clock = this.env.clock;
+ this.requests = this.env.requests;
+ this.fakeVhs = {
+ xhr: xhrFactory()
+ };
+ this.logLines = [];
+ this.oldDebugLog = videojs.log.debug;
+ videojs.log.debug = (...args) => {
+ this.logLines.push(args.join(' '));
+ };
+ });
+ hooks.afterEach(function(assert) {
+ if (this.loader) {
+ this.loader.dispose();
+ }
+ this.env.restore();
+ videojs.log.debug = this.oldDebugLog;
+ });
+
+ QUnit.module('sanity');
+
+ QUnit.test('verify that constructor sets options and event handlers', function(assert) {
+ const lastRequestTime = 15;
+ const manifest = {foo: 'bar'};
+ const manifestString = 'foo: bar';
+
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs,
+ manifest,
+ manifestString,
+ lastRequestTime
+ });
+
+ assert.equal(this.loader.uri(), 'foo.uri', 'uri set');
+ assert.equal(this.loader.manifest(), manifest, 'manifest set');
+ assert.equal(this.loader.manifestString_, manifestString, 'manifestString set');
+ assert.equal(this.loader.started(), false, 'not started');
+ assert.equal(this.loader.request(), null, 'no request');
+ assert.equal(this.loader.error(), null, 'no error');
+ assert.equal(this.loader.lastRequestTime(), lastRequestTime, 'last request time saved');
+ assert.equal(this.loader.getMediaRefreshTime_(), null, 'no media refresh time');
+
+ this.loader.logger_('foo');
+
+ assert.equal(this.logLines[0], 'VHS: PlaylistLoader > foo', 'logger logs as expected');
+ });
+
+ QUnit.module('#start()');
+
+ QUnit.test('only starts if not started', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ const calls = {};
+ const fns = ['refreshManifest_'];
+
+ fns.forEach((name) => {
+ calls[name] = 0;
+
+ this.loader[name] = () => {
+ calls[name]++;
+ };
+ });
+ assert.false(this.loader.started(), 'not started');
+
+ this.loader.start();
+ assert.true(this.loader.started(), 'still started');
+
+ fns.forEach(function(name) {
+ assert.equal(calls[name], 1, `called ${name}`);
+ });
+
+ this.loader.start();
+ fns.forEach(function(name) {
+ assert.equal(calls[name], 1, `still 1 call to ${name}`);
+ });
+
+ assert.true(this.loader.started(), 'still started');
+ });
+
+ QUnit.test('sets started to true', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {vhs: this.fakeVhs});
+
+ assert.equal(this.requests.length, 0, 'no requests');
+
+ this.loader.start();
+
+ assert.equal(this.loader.started(), true, 'is started');
+ assert.equal(this.requests.length, 1, 'added request');
+ });
+
+ QUnit.test('does not request until start', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {vhs: this.fakeVhs});
+
+ assert.equal(this.requests.length, 0, 'no requests');
+
+ this.loader.start();
+
+ assert.equal(this.requests.length, 1, 'one request');
+ });
+
+ QUnit.test('requests relative uri', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {vhs: this.fakeVhs});
+
+ assert.equal(this.requests.length, 0, 'no requests');
+
+ this.loader.start();
+
+ assert.equal(this.requests.length, 1, 'one request');
+ assert.equal(this.requests[0].uri, 'foo.uri');
+ });
+
+ QUnit.test('requests absolute uri', function(assert) {
+ this.loader = new PlaylistLoader(urlTo('foo.uri'), {vhs: this.fakeVhs});
+
+ assert.equal(this.requests.length, 0, 'no requests');
+
+ this.loader.start();
+ assert.equal(this.requests.length, 1, 'one request');
+ assert.equal(this.requests[0].uri, urlTo('foo.uri'), 'absolute uri');
+ });
+
+ QUnit.module('#refreshManifest_()');
+
+ QUnit.test('updates uri() with handleManifestRedirects', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs,
+ handleManifestRedirects: true
+ });
+
+ this.loader.started_ = true;
+ this.loader.refreshManifest_();
+
+ this.requests[0].respond(200, null, 'foo');
+
+ assert.equal(this.loader.uri(), urlTo('foo.uri'), 'redirected to absolute');
+ });
+
+ QUnit.test('called by refresh trigger', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs,
+ handleManifestRedirects: true
+ });
+
+ this.loader.started_ = true;
+ this.loader.trigger('refresh');
+
+ this.requests[0].respond(200, null, 'foo');
+
+ assert.equal(this.loader.uri(), urlTo('foo.uri'), 'redirected to absolute');
+ });
+
+ QUnit.test('sets lastRequestTime to now after request', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ this.loader.started_ = true;
+ this.loader.refreshManifest_();
+
+ this.requests[0].respond(200, null, 'foo');
+
+ assert.equal(this.loader.lastRequestTime(), 0, 'set last request time');
+ });
+
+ QUnit.test('sets lastRequestTime set to date header after request', function(assert) {
+ this.clock.restore();
+
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ this.loader.started_ = true;
+ this.loader.refreshManifest_();
+
+ const date = new Date();
+
+ this.requests[0].respond(200, {date: date.toString()}, 'foo');
+
+ assert.equal(this.loader.lastRequestTime(), Date.parse(date.toString()), 'set last request time');
+ });
+
+ QUnit.test('lastRequestTime set to now without date header', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ this.loader.started_ = true;
+ this.loader.refreshManifest_();
+
+ // set "now" to 20
+ this.clock.tick(20);
+
+ this.requests[0].respond(200, null, 'foo');
+
+ assert.equal(this.loader.lastRequestTime(), 20, 'set last request time');
+ });
+
+ QUnit.module('#parseManifest_()');
+
+ QUnit.test('sets variables and triggers updated in callback', function(assert) {
+ assert.expect(6);
+
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ const manifest = {foo: 'bar'};
+ const manifestString = '{foo: "bar"}';
+ let updatedCalled = false;
+
+ this.loader.on('updated', function() {
+ updatedCalled = true;
+ });
+
+ this.loader.parseManifest_ = (manifestString_, callback) => {
+ assert.equal(manifestString, manifestString_, 'manifestString passed in');
+ callback(manifest, true);
+ };
+
+ this.loader.started_ = true;
+ this.loader.refreshManifest_();
+
+ this.requests[0].respond(200, null, manifestString);
+
+ assert.equal(this.loader.manifest(), manifest, 'manifest added to loader');
+ assert.equal(this.loader.manifestString_, manifestString, 'manifestString added to loader');
+ assert.true(updatedCalled, 'updated was called');
+ });
+
+ QUnit.test('does not set anything if not updated', function(assert) {
+ assert.expect(6);
+
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ const manifestString = '{foo: "bar"}';
+ let updatedCalled = false;
+
+ this.loader.on('updated', function() {
+ updatedCalled = true;
+ });
+
+ this.loader.parseManifest_ = (manifestString_, callback) => {
+ assert.equal(manifestString, manifestString_, 'manifestString passed in');
+ callback(null, false);
+ };
+
+ this.loader.started_ = true;
+ this.loader.refreshManifest_();
+
+ this.requests[0].respond(200, null, manifestString);
+
+ assert.equal(this.loader.manifest(), null, 'manifest not added to loader');
+ assert.equal(this.loader.manifestString_, null, 'manifestString not added to loader');
+ assert.false(updatedCalled, 'updated was not called');
+ });
+
+ QUnit.module('#makeRequest_()');
+
+ QUnit.test('can request any url', function(assert) {
+ assert.expect(5);
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ // fake started
+ this.loader.started_ = true;
+
+ this.loader.makeRequest_({uri: 'bar.uri'}, function(request, wasRedirected) {
+ assert.equal(wasRedirected, false, 'not redirected');
+ assert.equal(request.responseText, 'bar', 'got correct response');
+ });
+
+ assert.equal(this.requests[0], this.loader.request_, 'set request on loader');
+
+ this.requests[0].respond(200, null, 'bar');
+ });
+
+ QUnit.test('uses withCredentials from loader options', function(assert) {
+ assert.expect(4);
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs,
+ withCredentials: true
+ });
+
+ // fake started
+ this.loader.started_ = true;
+
+ this.loader.makeRequest_({uri: 'bar.uri'}, function(request, wasRedirected) {
+ assert.equal(wasRedirected, false, 'not redirected');
+ assert.equal(request.responseText, 'bar', 'got correct response');
+ });
+
+ assert.equal(this.requests[0], this.loader.request_, 'set request on loader');
+ assert.true(this.loader.request_.withCredentials, 'set with credentials');
+ });
+
+ QUnit.test('wasRedirected is true with handleManifestRedirects and different uri', function(assert) {
+ assert.expect(5);
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs,
+ handleManifestRedirects: true
+ });
+
+ // fake started
+ this.loader.started_ = true;
+
+ this.loader.makeRequest_({uri: 'bar.uri'}, function(request, wasRedirected) {
+ assert.equal(wasRedirected, true, 'was redirected');
+ assert.equal(request.responseText, 'bar', 'got correct response');
+ });
+
+ assert.equal(this.requests[0], this.loader.request_, 'set request on loader');
+
+ this.requests[0].responseURL = urlTo('foo.uri');
+ this.requests[0].respond(200, null, 'bar');
+ });
+
+ QUnit.test('does not complete request after dispose', function(assert) {
+ assert.expect(3);
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ // fake started
+ this.loader.started_ = true;
+
+ this.loader.makeRequest_({uri: 'bar.uri'}, function(request, wasRedirected) {
+ assert.false(true, 'we do not get into callback');
+ });
+
+ assert.equal(this.requests[0], this.loader.request_, 'set request on loader');
+
+ // fake disposed
+ this.loader.isDisposed_ = true;
+
+ this.requests[0].respond(200, null, 'bar');
+ });
+
+ QUnit.test('triggers error if not started', function(assert) {
+ assert.expect(5);
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+ let errorTriggered = false;
+
+ this.loader.on('error', function() {
+ errorTriggered = true;
+ });
+
+ this.loader.makeRequest_({uri: 'bar.uri'}, function(request, wasRedirected) {
+ assert.false(true, 'we do not get into callback');
+ });
+
+ const expectedError = {
+ message: 'makeRequest_ cannot be called before started!'
+ };
+
+ assert.deepEqual(this.loader.error(), expectedError, 'expected error');
+ assert.equal(this.loader.request(), null, 'no request');
+ assert.true(errorTriggered, 'error was triggered');
+ });
+
+ QUnit.test('triggers error with code 4 if http request error code above 500', function(assert) {
+ assert.expect(5);
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+ let errorTriggered = false;
+
+ this.loader.on('error', function() {
+ errorTriggered = true;
+ });
+
+ this.loader.started_ = true;
+ this.loader.makeRequest_({uri: 'bar.uri'}, function(request, wasRedirected) {
+ assert.false(true, 'we do not get into callback');
+ });
+
+ this.requests[0].respond(505, null, 'bad request foo bar');
+
+ const expectedError = {
+ message: 'Request error at URI bar.uri'
+ };
+
+ assert.deepEqual(this.loader.error(), expectedError, 'expected error');
+ assert.equal(this.loader.request(), null, 'no request');
+ assert.true(errorTriggered, 'error was triggered');
+ });
+
+ QUnit.test('triggers error with code 2 if http request error code below 500', function(assert) {
+ assert.expect(5);
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+ let errorTriggered = false;
+
+ this.loader.on('error', function() {
+ errorTriggered = true;
+ });
+
+ this.loader.started_ = true;
+ this.loader.makeRequest_({uri: 'bar.uri'}, function(request, wasRedirected) {
+ assert.false(true, 'we do not get into callback');
+ });
+
+ this.requests[0].respond(404, null, 'bad request foo bar');
+
+ const expectedError = {
+ message: 'Request error at URI bar.uri'
+ };
+
+ assert.deepEqual(this.loader.error(), expectedError, 'expected error');
+ assert.equal(this.loader.request(), null, 'no request');
+ assert.true(errorTriggered, 'error was triggered');
+ });
+
+ QUnit.test('handleErrors: false causes errors to be passed along, not triggered', function(assert) {
+ assert.expect(6);
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+ let errorTriggered = false;
+
+ this.loader.on('error', function() {
+ errorTriggered = true;
+ });
+
+ this.loader.started_ = true;
+ this.loader.makeRequest_({uri: 'bar.uri', handleErrors: false}, function(request, wasRedirected, error) {
+ assert.ok(error, 'error was passed in');
+ });
+
+ this.requests[0].respond(404, null, 'bad request foo bar');
+
+ assert.notOk(this.loader.error(), 'no error');
+ assert.equal(this.loader.request(), null, 'no request');
+ assert.false(errorTriggered, 'error was triggered');
+ });
+
+ QUnit.module('#stop()');
+
+ QUnit.test('only stops things if started', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ const calls = {};
+ const fns = ['stopRequest', 'clearMediaRefreshTimeout_'];
+
+ fns.forEach((name) => {
+ calls[name] = 0;
+
+ this.loader[name] = () => {
+ calls[name]++;
+ };
+ });
+
+ this.loader.stop();
+ fns.forEach(function(name) {
+ assert.equal(calls[name], 0, `no calls to ${name}`);
+ });
+
+ this.loader.started_ = true;
+
+ this.loader.stop();
+ fns.forEach(function(name) {
+ assert.equal(calls[name], 1, `1 call to ${name}`);
+ });
+
+ assert.false(this.loader.started(), 'not started');
+ });
+
+ QUnit.module('#dispose()');
+
+ QUnit.test('works as expected', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ let stopCalled = false;
+ let disposeTriggered = false;
+
+ this.loader.on('dispose', function() {
+ disposeTriggered = true;
+ });
+
+ this.loader.stop = function() {
+ stopCalled = true;
+ };
+
+ this.loader.dispose();
+
+ assert.true(stopCalled, 'stop was called');
+ assert.true(disposeTriggered, 'dispose was triggered');
+ assert.true(this.loader.isDisposed_, 'is disposed was set');
+ });
+
+ QUnit.module('#stopRequest()');
+
+ QUnit.test('does not error without a request', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ try {
+ this.loader.stopRequest();
+ assert.true(true, 'did not throw');
+ } catch (e) {
+ assert.false(true, `threw an error ${e}`);
+ }
+ });
+
+ QUnit.test('calls abort, clears this.request_, and clears onreadystatechange', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ this.loader.start();
+
+ const oldRequest = this.loader.request();
+ let abortCalled = false;
+
+ oldRequest.abort = function() {
+ abortCalled = true;
+ };
+
+ assert.ok(oldRequest, 'have a request in flight');
+
+ oldRequest.onreadystatechange = function() {};
+
+ this.loader.stopRequest();
+
+ assert.equal(oldRequest.onreadystatechange, null, 'no onreadystatechange');
+ assert.true(abortCalled, 'abort was called');
+ assert.equal(this.loader.request(), null, 'no current request anymore');
+ });
+
+ QUnit.module('#setMediaRefreshTime_()');
+
+ QUnit.test('sets media refresh time with getMediaRefreshTime_() by default', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+ let refreshTriggered = false;
+
+ this.loader.on('refresh', function() {
+ refreshTriggered = true;
+ });
+
+ this.loader.getMediaRefreshTime_ = () => 20;
+ this.loader.setMediaRefreshTimeout_();
+
+ assert.ok(this.loader.refreshTimeout_, 'has a refreshTimeout_');
+
+ this.clock.tick(20);
+ assert.true(refreshTriggered, 'refresh was triggered');
+ assert.ok(this.loader.refreshTimeout_, 'refresh timeout added again');
+
+ this.loader.clearMediaRefreshTimeout_();
+ });
+
+ QUnit.test('sets media refresh time on updated', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+ let refreshTriggered = false;
+
+ this.loader.on('refresh', function() {
+ refreshTriggered = true;
+ });
+
+ this.loader.getMediaRefreshTime_ = () => 20;
+ this.loader.trigger('updated');
+
+ assert.ok(this.loader.refreshTimeout_, 'has a refreshTimeout_');
+
+ this.clock.tick(20);
+ assert.true(refreshTriggered, 'refresh was triggered');
+ assert.ok(this.loader.refreshTimeout_, 'refresh timeout added again');
+
+ this.loader.clearMediaRefreshTimeout_();
+ });
+
+ QUnit.test('not re-added if getMediaRefreshTime_ returns null', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+ let refreshTriggered = false;
+
+ this.loader.on('refresh', function() {
+ refreshTriggered = true;
+ });
+
+ this.loader.getMediaRefreshTime_ = () => 20;
+ this.loader.setMediaRefreshTimeout_();
+
+ assert.ok(this.loader.refreshTimeout_, 'has a refreshTimeout_');
+
+ this.loader.getMediaRefreshTime_ = () => null;
+
+ this.clock.tick(20);
+ assert.true(refreshTriggered, 'refresh was triggered');
+ assert.equal(this.loader.refreshTimeout_, null, 'refresh timeout not added again');
+
+ });
+
+ QUnit.test('does nothing when disposed', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+
+ this.loader.isDisposed_ = true;
+ this.loader.getMediaRefreshTime_ = () => 20;
+ this.loader.setMediaRefreshTimeout_();
+
+ assert.equal(this.loader.refreshTimeout_, null, 'no refreshTimeout_');
+ });
+
+ QUnit.module('#clearMediaRefreshTime_()');
+
+ QUnit.test('not re-added if getMediaRefreshTime_ returns null', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+ let refreshTriggered = false;
+
+ this.loader.on('refresh', function() {
+ refreshTriggered = true;
+ });
+
+ this.loader.getMediaRefreshTime_ = () => 20;
+ this.loader.setMediaRefreshTimeout_();
+
+ assert.ok(this.loader.refreshTimeout_, 'has a refreshTimeout_');
+
+ this.loader.clearMediaRefreshTimeout_();
+
+ assert.equal(this.loader.refreshTimeout_, null, 'refreshTimeout_ removed');
+ this.clock.tick(20);
+ assert.false(refreshTriggered, 'refresh not triggered as timeout was cleared');
+ });
+
+ QUnit.test('does not throw if we have no refreshTimeout_', function(assert) {
+ this.loader = new PlaylistLoader('foo.uri', {
+ vhs: this.fakeVhs
+ });
+ try {
+ this.loader.clearMediaRefreshTimeout_();
+ assert.true(true, 'did not throw an error');
+ } catch (e) {
+ assert.true(false, `threw an error ${e}`);
+ }
+ });
+});
+
diff --git a/test/playlist-loader/utils.test.js b/test/playlist-loader/utils.test.js
new file mode 100644
index 000000000..8c4309b1f
--- /dev/null
+++ b/test/playlist-loader/utils.test.js
@@ -0,0 +1,942 @@
+import QUnit from 'qunit';
+import {
+ forEachMediaGroup,
+ mergeSegments,
+ mergeSegment,
+ mergeMedia,
+ forEachPlaylist,
+ mergeManifest
+} from '../../src/playlist-loader/utils.js';
+import {absoluteUrl} from '../test-helpers.js';
+
+QUnit.module('Playlist Loader Utils', function(hooks) {
+
+ QUnit.module('forEachMediaGroup');
+
+ QUnit.test('does not error when passed null', function(assert) {
+ assert.expect(1);
+ let i = 0;
+
+ forEachMediaGroup(null, function(props, type, group, label) {
+ i++;
+ });
+
+ assert.equal(i, 0, 'did not loop');
+ });
+
+ QUnit.test('does not error without groups', function(assert) {
+ assert.expect(1);
+ const manifest = {};
+
+ let i = 0;
+
+ forEachMediaGroup(manifest, function(props, type, group, label) {
+ i++;
+ });
+
+ assert.equal(i, 0, 'did not loop');
+ });
+
+ QUnit.test('does not error with no group keys', function(assert) {
+ assert.expect(1);
+ const manifest = {
+ mediaGroups: {
+ SUBTITLES: {}
+ }
+ };
+
+ let i = 0;
+
+ forEachMediaGroup(manifest, function(props, type, group, label) {
+ i++;
+ });
+
+ assert.equal(i, 0, 'did not loop');
+ });
+
+ QUnit.test('does not error with null group key', function(assert) {
+ assert.expect(1);
+ const manifest = {
+ mediaGroups: {
+ SUBTITLES: {en: null}
+ }
+ };
+
+ let i = 0;
+
+ forEachMediaGroup(manifest, function(props, type, group, label) {
+ i++;
+ });
+
+ assert.equal(i, 0, 'did not loop');
+ });
+
+ QUnit.test('does not error with null label key', function(assert) {
+ assert.expect(1);
+ const manifest = {
+ mediaGroups: {
+ SUBTITLES: {en: {main: null}}
+ }
+ };
+
+ let i = 0;
+
+ forEachMediaGroup(manifest, function(props, type, group, label) {
+ i++;
+ });
+
+ assert.equal(i, 0, 'did not loop');
+ });
+
+ QUnit.test('does not error with empty label keys', function(assert) {
+ assert.expect(1);
+ const manifest = {
+ mediaGroups: {
+ SUBTITLES: {en: {}}
+ }
+ };
+
+ let i = 0;
+
+ forEachMediaGroup(manifest, function(props, type, group, label) {
+ i++;
+ });
+
+ assert.equal(i, 0, 'did not loop');
+ });
+
+ QUnit.test('can loop over subtitle groups', function(assert) {
+ assert.expect(16);
+ const manifest = {
+ mediaGroups: {
+ SUBTITLES: {
+ en: {
+ main: {foo: 'bar'},
+ alt: {fizz: 'buzz'}
+ },
+ es: {
+ main: {a: 'b'},
+ alt: {yes: 'no'}
+ }
+ }
+ }
+ };
+
+ let i = 0;
+
+ forEachMediaGroup(manifest, function(props, type, group, label) {
+ if (i === 0) {
+ assert.deepEqual(props, {foo: 'bar'});
+ assert.deepEqual(type, 'SUBTITLES');
+ assert.deepEqual(group, 'en');
+ assert.deepEqual(label, 'main');
+ } else if (i === 1) {
+ assert.deepEqual(props, {fizz: 'buzz'});
+ assert.deepEqual(type, 'SUBTITLES');
+ assert.deepEqual(group, 'en');
+ assert.deepEqual(label, 'alt');
+ } else if (i === 2) {
+ assert.deepEqual(props, {a: 'b'});
+ assert.deepEqual(type, 'SUBTITLES');
+ assert.deepEqual(group, 'es');
+ assert.deepEqual(label, 'main');
+ } else if (i === 3) {
+ assert.deepEqual(props, {yes: 'no'});
+ assert.deepEqual(type, 'SUBTITLES');
+ assert.deepEqual(group, 'es');
+ assert.deepEqual(label, 'alt');
+ }
+
+ i++;
+ });
+
+ });
+
+ QUnit.test('can loop over audio groups', function(assert) {
+ assert.expect(16);
+ const manifest = {
+ mediaGroups: {
+ AUDIO: {
+ en: {
+ main: {foo: 'bar'},
+ alt: {fizz: 'buzz'}
+ },
+ es: {
+ main: {a: 'b'},
+ alt: {yes: 'no'}
+ }
+ }
+ }
+ };
+
+ let i = 0;
+
+ forEachMediaGroup(manifest, function(props, type, group, label) {
+ if (i === 0) {
+ assert.deepEqual(props, {foo: 'bar'});
+ assert.deepEqual(type, 'AUDIO');
+ assert.deepEqual(group, 'en');
+ assert.deepEqual(label, 'main');
+ } else if (i === 1) {
+ assert.deepEqual(props, {fizz: 'buzz'});
+ assert.deepEqual(type, 'AUDIO');
+ assert.deepEqual(group, 'en');
+ assert.deepEqual(label, 'alt');
+ } else if (i === 2) {
+ assert.deepEqual(props, {a: 'b'});
+ assert.deepEqual(type, 'AUDIO');
+ assert.deepEqual(group, 'es');
+ assert.deepEqual(label, 'main');
+ } else if (i === 3) {
+ assert.deepEqual(props, {yes: 'no'});
+ assert.deepEqual(type, 'AUDIO');
+ assert.deepEqual(group, 'es');
+ assert.deepEqual(label, 'alt');
+ }
+
+ i++;
+ });
+ });
+
+ QUnit.test('can loop over both groups', function(assert) {
+ assert.expect(16);
+ const manifest = {
+ mediaGroups: {
+ AUDIO: {
+ en: {
+ main: {foo: 'bar'}
+ },
+ es: {
+ main: {a: 'b'}
+ }
+ },
+ SUBTITLES: {
+ en: {
+ main: {foo: 'bar'}
+ },
+ es: {
+ main: {a: 'b'}
+ }
+ }
+ }
+ };
+
+ let i = 0;
+
+ forEachMediaGroup(manifest, function(props, type, group, label) {
+ if (i === 0) {
+ assert.deepEqual(props, {foo: 'bar'});
+ assert.deepEqual(type, 'AUDIO');
+ assert.deepEqual(group, 'en');
+ assert.deepEqual(label, 'main');
+ } else if (i === 1) {
+ assert.deepEqual(props, {a: 'b'});
+ assert.deepEqual(type, 'AUDIO');
+ assert.deepEqual(group, 'es');
+ assert.deepEqual(label, 'main');
+ } else if (i === 2) {
+ assert.deepEqual(props, {foo: 'bar'});
+ assert.deepEqual(type, 'SUBTITLES');
+ assert.deepEqual(group, 'en');
+ assert.deepEqual(label, 'main');
+ } else if (i === 3) {
+ assert.deepEqual(props, {a: 'b'});
+ assert.deepEqual(type, 'SUBTITLES');
+ assert.deepEqual(group, 'es');
+ assert.deepEqual(label, 'main');
+ }
+ i++;
+ });
+ });
+
+ QUnit.test('can loop over both groups', function(assert) {
+ assert.expect(16);
+ const manifest = {
+ mediaGroups: {
+ AUDIO: {
+ en: {
+ main: {foo: 'bar'}
+ },
+ es: {
+ main: {a: 'b'}
+ }
+ },
+ SUBTITLES: {
+ en: {
+ main: {foo: 'bar'}
+ },
+ es: {
+ main: {a: 'b'}
+ }
+ }
+ }
+ };
+
+ let i = 0;
+
+ forEachMediaGroup(manifest, function(props, type, group, label) {
+ if (i === 0) {
+ assert.deepEqual(props, {foo: 'bar'});
+ assert.deepEqual(type, 'AUDIO');
+ assert.deepEqual(group, 'en');
+ assert.deepEqual(label, 'main');
+ } else if (i === 1) {
+ assert.deepEqual(props, {a: 'b'});
+ assert.deepEqual(type, 'AUDIO');
+ assert.deepEqual(group, 'es');
+ assert.deepEqual(label, 'main');
+ } else if (i === 2) {
+ assert.deepEqual(props, {foo: 'bar'});
+ assert.deepEqual(type, 'SUBTITLES');
+ assert.deepEqual(group, 'en');
+ assert.deepEqual(label, 'main');
+ } else if (i === 3) {
+ assert.deepEqual(props, {a: 'b'});
+ assert.deepEqual(type, 'SUBTITLES');
+ assert.deepEqual(group, 'es');
+ assert.deepEqual(label, 'main');
+ }
+ i++;
+ });
+ });
+
+ QUnit.test('can stop looping by returning a true value', function(assert) {
+ assert.expect(1);
+ const manifest = {
+ mediaGroups: {
+ AUDIO: {
+ en: {
+ main: {foo: 'bar'}
+ },
+ es: {
+ main: {a: 'b'}
+ }
+ },
+ SUBTITLES: {
+ en: {
+ main: {foo: 'bar'}
+ },
+ es: {
+ main: {a: 'b'}
+ }
+ }
+ }
+ };
+
+ let i = 0;
+
+ forEachMediaGroup(manifest, function(props, type, group, label) {
+ i++;
+
+ if (i === 2) {
+ return true;
+ }
+
+ });
+
+ assert.equal(i, 2, 'loop was stopped early');
+ });
+
+ QUnit.module('mergeSegments');
+
+ QUnit.test('no oldSegments', function(assert) {
+ const {updated, segments} = mergeSegments({
+ oldSegments: null,
+ newSegments: [{duration: 1}]
+ });
+
+ assert.true(updated, 'was updated');
+ assert.deepEqual(
+ segments,
+ [{duration: 1}],
+ 'result as expected'
+ );
+ });
+
+ QUnit.test('keeps timing info from old segment', function(assert) {
+ const {updated, segments} = mergeSegments({
+ oldSegments: [{duration: 1, timingInfo: {audio: {start: 1, end: 2}}}],
+ newSegments: [{duration: 1}]
+ });
+
+ assert.false(updated, 'was not updated');
+ assert.deepEqual(
+ segments,
+ [{duration: 1, timingInfo: {audio: {start: 1, end: 2}}}],
+ 'result as expected'
+ );
+ });
+
+ QUnit.test('keeps map from old segment', function(assert) {
+ const {updated, segments} = mergeSegments({
+ oldSegments: [{map: {uri: 'foo.uri'}, duration: 1}],
+ newSegments: [{duration: 1}]
+ });
+
+ assert.false(updated, 'was not updated');
+ assert.deepEqual(
+ segments,
+ [{duration: 1, map: {uri: 'foo.uri', resolvedUri: absoluteUrl('foo.uri')}}],
+ 'result as expected'
+ );
+ });
+
+ QUnit.test('adds map to all new segment', function(assert) {
+ const {updated, segments} = mergeSegments({
+ oldSegments: [],
+ newSegments: [{map: {uri: 'foo.uri'}, duration: 1}, {duration: 1}]
+ });
+
+ assert.true(updated, 'was updated');
+ assert.deepEqual(
+ segments,
+ [
+ {duration: 1, map: {uri: 'foo.uri', resolvedUri: absoluteUrl('foo.uri')}},
+ {duration: 1, map: {uri: 'foo.uri', resolvedUri: absoluteUrl('foo.uri')}}
+ ],
+ 'result as expected'
+ );
+ });
+
+ QUnit.test('resolves all segment uris', function(assert) {
+ const {updated, segments} = mergeSegments({
+ oldSegments: [],
+ newSegments: [{
+ uri: 'segment.mp4',
+ map: {
+ uri: 'init.mp4',
+ key: {uri: 'mapkey.uri'}
+ },
+ key: {uri: 'key.uri'},
+ parts: [{uri: 'part1.uri'}, {resolvedUri: absoluteUrl('part2.uri'), uri: 'part2.uri'}],
+ preloadHints: [{uri: 'hint1.uri'}, {resolvedUri: absoluteUrl('hint2.uri'), uri: 'hint2.uri'}]
+ }]
+ });
+
+ assert.true(updated, 'was updated');
+ assert.deepEqual(segments, [{
+ uri: 'segment.mp4',
+ resolvedUri: absoluteUrl('segment.mp4'),
+ map: {
+ uri: 'init.mp4',
+ resolvedUri: absoluteUrl('init.mp4'),
+ key: {uri: 'mapkey.uri', resolvedUri: absoluteUrl('mapkey.uri')}
+ },
+ key: {uri: 'key.uri', resolvedUri: absoluteUrl('key.uri')},
+ parts: [
+ {uri: 'part1.uri', resolvedUri: absoluteUrl('part1.uri')},
+ {uri: 'part2.uri', resolvedUri: absoluteUrl('part2.uri')}
+ ],
+ preloadHints: [
+ {uri: 'hint1.uri', resolvedUri: absoluteUrl('hint1.uri')},
+ {uri: 'hint2.uri', resolvedUri: absoluteUrl('hint2.uri')}
+ ]
+ }], 'result as expected');
+ });
+
+ QUnit.test('resolves all segment uris using baseUri', function(assert) {
+ const baseUri = 'http://example.com';
+ const {updated, segments} = mergeSegments({
+ baseUri: 'http://example.com/media.m3u8',
+ oldSegments: [],
+ newSegments: [{
+ uri: 'segment.mp4',
+ map: {
+ uri: 'init.mp4',
+ key: {uri: 'mapkey.uri'}
+ },
+ key: {uri: 'key.uri'},
+ parts: [{uri: 'part.uri'}],
+ preloadHints: [{uri: 'hint.uri'}]
+ }]
+ });
+
+ assert.true(updated, 'was updated');
+ assert.deepEqual(segments, [{
+ uri: 'segment.mp4',
+ resolvedUri: `${baseUri}/segment.mp4`,
+ map: {
+ uri: 'init.mp4',
+ resolvedUri: `${baseUri}/init.mp4`,
+ key: {uri: 'mapkey.uri', resolvedUri: `${baseUri}/mapkey.uri`}
+ },
+ key: {uri: 'key.uri', resolvedUri: `${baseUri}/key.uri`},
+ parts: [{uri: 'part.uri', resolvedUri: `${baseUri}/part.uri`}],
+ preloadHints: [{uri: 'hint.uri', resolvedUri: `${baseUri}/hint.uri`}]
+ }], 'result as expected');
+ });
+
+ QUnit.test('can merge on an offset', function(assert) {
+ const {updated, segments} = mergeSegments({
+ oldSegments: [{uri: '1', duration: 1}, {uri: '2', duration: 1}, {uri: '3', duration: 1, foo: 'bar'}],
+ newSegments: [{uri: '2', duration: 1}, {uri: '3', duration: 1}],
+ offset: 1
+ });
+
+ assert.true(updated, 'was updated');
+ assert.deepEqual(
+ segments,
+ [
+ {duration: 1, uri: '2', resolvedUri: absoluteUrl('2')},
+ {duration: 1, uri: '3', resolvedUri: absoluteUrl('3'), foo: 'bar'}
+ ],
+ 'result as expected'
+ );
+ });
+
+ QUnit.module('mergeSegment');
+
+ QUnit.test('updated without old segment', function(assert) {
+ const oldSegment = null;
+ const newSegment = {uri: 'foo.mp4'};
+ const result = mergeSegment(oldSegment, newSegment);
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(result.segment, {uri: 'foo.mp4'}, 'as expected');
+ });
+
+ QUnit.test('updated if new segment has no parts', function(assert) {
+ const oldSegment = {uri: 'foo.mp4', parts: [{uri: 'foo-p1.mp4'}]};
+ const newSegment = {uri: 'foo.mp4'};
+ const result = mergeSegment(oldSegment, newSegment);
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(result.segment, {uri: 'foo.mp4'}, 'as expected');
+ });
+
+ QUnit.test('updated if new segment has no preloadHints', function(assert) {
+ const oldSegment = {uri: 'foo.mp4', preloadHints: [{uri: 'foo-p1.mp4'}]};
+ const newSegment = {uri: 'foo.mp4'};
+ const result = mergeSegment(oldSegment, newSegment);
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(result.segment, {uri: 'foo.mp4'}, 'as expected');
+ });
+
+ QUnit.test('updated with different number of parts', function(assert) {
+ const oldSegment = {uri: 'foo.mp4', parts: [{uri: 'foo-p1.mp4'}]};
+ const newSegment = {uri: 'foo.mp4', parts: [{uri: 'foo-p1.mp4'}, {uri: 'foo-p2.mp4'}]};
+ const result = mergeSegment(oldSegment, newSegment);
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(result.segment, {
+ uri: 'foo.mp4',
+ parts: [
+ {uri: 'foo-p1.mp4'},
+ {uri: 'foo-p2.mp4'}
+ ]
+ }, 'as expected');
+ });
+
+ QUnit.test('preload removed if new segment lacks it', function(assert) {
+ const oldSegment = {preload: true};
+ const newSegment = {uri: 'foo.mp4'};
+ const result = mergeSegment(oldSegment, newSegment);
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(result.segment, {uri: 'foo.mp4'}, 'as expected');
+ });
+
+ QUnit.test('if old segment was not skipped skipped is removed', function(assert) {
+ const oldSegment = {uri: 'foo.mp4'};
+ const newSegment = {skipped: true};
+ const result = mergeSegment(oldSegment, newSegment);
+
+ assert.false(result.updated, 'was not updated');
+ assert.deepEqual(result.segment, {uri: 'foo.mp4'}, 'as expected');
+ });
+
+ QUnit.test('merges part properties', function(assert) {
+ const oldSegment = {uri: 'foo.mp4', parts: [{uri: 'part', foo: 'bar'}]};
+ const newSegment = {uri: 'foo.mp4', parts: [{uri: 'part'}]};
+ const result = mergeSegment(oldSegment, newSegment);
+
+ assert.false(result.updated, 'was not updated');
+ assert.deepEqual(result.segment, {uri: 'foo.mp4', parts: [{uri: 'part', foo: 'bar'}]}, 'as expected');
+ });
+
+ QUnit.module('mergeMedia');
+
+ QUnit.test('is updated without old media', function(assert) {
+ const oldMedia = null;
+ const newMedia = {mediaSequence: 0};
+ const result = mergeMedia({
+ oldMedia,
+ newMedia,
+ baseUri: null
+ });
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(
+ result.media,
+ {mediaSequence: 0, segments: []},
+ 'as expected'
+ );
+ });
+
+ QUnit.test('is updated if key added', function(assert) {
+ const oldMedia = {mediaSequence: 0};
+ const newMedia = {mediaSequence: 0, endList: true};
+ const result = mergeMedia({
+ oldMedia,
+ newMedia,
+ baseUri: null
+ });
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(
+ result.media,
+ {mediaSequence: 0, segments: [], endList: true},
+ 'as expected'
+ );
+ });
+
+ QUnit.test('is updated if key changes', function(assert) {
+ const oldMedia = {mediaSequence: 0, preloadSegment: {parts: [{duration: 1}]}};
+ const newMedia = {mediaSequence: 0, preloadSegment: {parts: [{duration: 1}, {duration: 1}]}};
+ const result = mergeMedia({
+ oldMedia,
+ newMedia,
+ baseUri: null
+ });
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(
+ result.media,
+ {
+ mediaSequence: 0,
+ preloadSegment: {parts: [{duration: 1}, {duration: 1}]},
+ segments: []
+ },
+ 'as expected'
+ );
+ });
+
+ QUnit.test('is updated if key removed', function(assert) {
+ const oldMedia = {mediaSequence: 0, preloadSegment: {parts: [{duration: 1}]}};
+ const newMedia = {mediaSequence: 0};
+ const result = mergeMedia({
+ oldMedia,
+ newMedia,
+ baseUri: null
+ });
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(
+ result.media,
+ {
+ mediaSequence: 0,
+ segments: []
+ },
+ 'as expected'
+ );
+ });
+
+ QUnit.module('forEachPlaylist');
+
+ QUnit.test('loops over playlists and group playlists', function(assert) {
+ const manifest = {
+ playlists: [{one: 'one'}, {two: 'two'}],
+ mediaGroups: {
+ AUDIO: {
+ en: {
+ main: {foo: 'bar', playlists: [{three: 'three'}]}
+ },
+ es: {
+ main: {a: 'b', playlists: [{four: 'four' }]}
+ }
+ },
+ SUBTITLES: {
+ en: {
+ main: {foo: 'bar', playlists: [{five: 'five'}]}
+ },
+ es: {
+ main: {a: 'b', playlists: [{six: 'six'}]}
+ }
+ }
+ }
+ };
+
+ let i = 0;
+
+ forEachPlaylist(manifest, function(playlist, index, array) {
+ if (i === 0) {
+ assert.deepEqual(playlist, {one: 'one'}, 'playlist as expected');
+ assert.equal(index, 0, 'index as expected');
+ assert.equal(array, manifest.playlists, 'array is correct');
+ } else if (i === 1) {
+ assert.deepEqual(playlist, {two: 'two'}, 'playlist as expected');
+ assert.equal(index, 1, 'index as expected');
+ assert.equal(array, manifest.playlists, 'array is correct');
+ } else if (i === 2) {
+ assert.deepEqual(playlist, {three: 'three'}, 'playlist as expected');
+ assert.equal(index, 0, 'index as expected');
+ assert.equal(array, manifest.mediaGroups.AUDIO.en.main.playlists, 'array is correct');
+ } else if (i === 3) {
+ assert.deepEqual(playlist, {four: 'four'}, 'playlist as expected');
+ assert.equal(index, 0, 'index as expected');
+ assert.equal(array, manifest.mediaGroups.AUDIO.es.main.playlists, 'array is correct');
+ } else if (i === 4) {
+ assert.deepEqual(playlist, {five: 'five'}, 'playlist as expected');
+ assert.equal(index, 0, 'index as expected');
+ assert.equal(array, manifest.mediaGroups.SUBTITLES.en.main.playlists, 'array is correct');
+ } else if (i === 5) {
+ assert.deepEqual(playlist, {six: 'six'}, 'playlist as expected');
+ assert.equal(index, 0, 'index as expected');
+ assert.equal(array, manifest.mediaGroups.SUBTITLES.es.main.playlists, 'array is correct');
+ }
+ i++;
+ });
+
+ assert.equal(i, 6, 'six playlists');
+ });
+
+ QUnit.test('loops over just groups', function(assert) {
+ const manifest = {
+ mediaGroups: {
+ AUDIO: {
+ en: {
+ main: {foo: 'bar', playlists: [{three: 'three'}]}
+ },
+ es: {
+ main: {a: 'b', playlists: [{four: 'four' }]}
+ }
+ },
+ SUBTITLES: {
+ en: {
+ main: {foo: 'bar', playlists: [{five: 'five'}]}
+ },
+ es: {
+ main: {a: 'b', playlists: [{six: 'six'}]}
+ }
+ }
+ }
+ };
+
+ let i = 0;
+
+ forEachPlaylist(manifest, function(playlist, index, array) {
+ if (i === 0) {
+ assert.deepEqual(playlist, {three: 'three'}, 'playlist as expected');
+ assert.equal(index, 0, 'index as expected');
+ assert.equal(array, manifest.mediaGroups.AUDIO.en.main.playlists, 'array is correct');
+ } else if (i === 1) {
+ assert.deepEqual(playlist, {four: 'four'}, 'playlist as expected');
+ assert.equal(index, 0, 'index as expected');
+ assert.equal(array, manifest.mediaGroups.AUDIO.es.main.playlists, 'array is correct');
+ } else if (i === 2) {
+ assert.deepEqual(playlist, {five: 'five'}, 'playlist as expected');
+ assert.equal(index, 0, 'index as expected');
+ assert.equal(array, manifest.mediaGroups.SUBTITLES.en.main.playlists, 'array is correct');
+ } else if (i === 3) {
+ assert.deepEqual(playlist, {six: 'six'}, 'playlist as expected');
+ assert.equal(index, 0, 'index as expected');
+ assert.equal(array, manifest.mediaGroups.SUBTITLES.es.main.playlists, 'array is correct');
+ }
+ i++;
+ });
+
+ assert.equal(i, 4, 'four playlists');
+ });
+
+ QUnit.test('loops over playlists only', function(assert) {
+ const manifest = {
+ playlists: [{one: 'one'}, {two: 'two'}]
+ };
+
+ let i = 0;
+
+ forEachPlaylist(manifest, function(playlist, index, array) {
+ if (i === 0) {
+ assert.deepEqual(playlist, {one: 'one'}, 'playlist as expected');
+ assert.equal(index, 0, 'index as expected');
+ assert.equal(array, manifest.playlists, 'array is correct');
+ } else if (i === 1) {
+ assert.deepEqual(playlist, {two: 'two'}, 'playlist as expected');
+ assert.equal(index, 1, 'index as expected');
+ assert.equal(array, manifest.playlists, 'array is correct');
+ }
+ i++;
+ });
+
+ assert.equal(i, 2, 'two playlists');
+ });
+
+ QUnit.test('does not error when passed null', function(assert) {
+ assert.expect(1);
+ let i = 0;
+
+ forEachPlaylist(null, function(playlist, index, array) {
+ i++;
+ });
+
+ assert.equal(i, 0, 'did not loop');
+ });
+
+ QUnit.test('does not error without groups', function(assert) {
+ assert.expect(1);
+ const manifest = {};
+
+ let i = 0;
+
+ forEachPlaylist(manifest, function(playlist, index, array) {
+ i++;
+ });
+
+ assert.equal(i, 0, 'did not loop');
+ });
+
+ QUnit.test('can stop in media groups', function(assert) {
+ const manifest = {
+ mediaGroups: {
+ AUDIO: {
+ en: {
+ main: {foo: 'bar', playlists: [{three: 'three'}]}
+ },
+ es: {
+ main: {a: 'b', playlists: [{four: 'four' }]}
+ }
+ },
+ SUBTITLES: {
+ en: {
+ main: {foo: 'bar', playlists: [{five: 'five'}]}
+ },
+ es: {
+ main: {a: 'b', playlists: [{six: 'six'}]}
+ }
+ }
+ }
+ };
+
+ let i = 0;
+
+ forEachPlaylist(manifest, function(playlist, index, array) {
+ i++;
+ return true;
+ });
+
+ assert.equal(i, 1, 'looped once');
+ });
+
+ QUnit.test('can stop in playlists', function(assert) {
+ const manifest = {
+ playlists: [{one: 'one'}, {two: 'two'}]
+ };
+
+ let i = 0;
+
+ forEachPlaylist(manifest, function(playlist, index, array) {
+ i++;
+ return true;
+ });
+
+ assert.equal(i, 1, 'looped once');
+ });
+
+ QUnit.module('mergeManifest');
+
+ QUnit.test('is updated without manifest a', function(assert) {
+ const oldManifest = null;
+ const newManifest = {mediaSequence: 0};
+ const result = mergeManifest(oldManifest, newManifest);
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(
+ result.manifest,
+ {mediaSequence: 0},
+ 'as expected'
+ );
+ });
+
+ QUnit.test('is updated if b lack key that a has', function(assert) {
+ const oldManifest = {mediaSequence: 0, foo: 'bar'};
+ const newManifest = {mediaSequence: 0};
+ const result = mergeManifest(oldManifest, newManifest);
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(
+ result.manifest,
+ {mediaSequence: 0},
+ 'as expected'
+ );
+ });
+
+ QUnit.test('is updated if a lack key that b has', function(assert) {
+ const oldManifest = {mediaSequence: 0};
+ const newManifest = {mediaSequence: 0, foo: 'bar'};
+ const result = mergeManifest(oldManifest, newManifest);
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(
+ result.manifest,
+ {mediaSequence: 0, foo: 'bar'},
+ 'as expected'
+ );
+ });
+
+ QUnit.test('is updated if key value is different', function(assert) {
+ const oldManifest = {mediaSequence: 0};
+ const newManifest = {mediaSequence: 1};
+ const result = mergeManifest(oldManifest, newManifest);
+
+ assert.true(result.updated, 'was updated');
+ assert.deepEqual(
+ result.manifest,
+ {mediaSequence: 1},
+ 'as expected'
+ );
+ });
+
+ QUnit.test('is not updated if key value is the same', function(assert) {
+ const oldManifest = {mediaSequence: 0};
+ const newManifest = {mediaSequence: 0};
+ const result = mergeManifest(oldManifest, newManifest);
+
+ assert.false(result.updated, 'was not updated');
+ assert.deepEqual(
+ result.manifest,
+ {mediaSequence: 0},
+ 'as expected'
+ );
+ });
+
+ QUnit.test('is not updated if key value is the same', function(assert) {
+ const oldManifest = {mediaSequence: 0};
+ const newManifest = {mediaSequence: 0};
+ const result = mergeManifest(oldManifest, newManifest);
+
+ assert.false(result.updated, 'was not updated');
+ assert.deepEqual(
+ result.manifest,
+ {mediaSequence: 0},
+ 'as expected'
+ );
+ });
+
+ QUnit.test('is not updated if key value is changed but ignored', function(assert) {
+ const oldManifest = {mediaSequence: 0};
+ const newManifest = {mediaSequence: 1};
+ const result = mergeManifest(oldManifest, newManifest, ['mediaSequence']);
+
+ assert.false(result.updated, 'was not updated');
+ assert.deepEqual(
+ result.manifest,
+ {mediaSequence: 1},
+ 'as expected'
+ );
+ });
+
+ QUnit.test('excluded key is not brought over', function(assert) {
+ const oldManifest = {mediaSequence: 0, foo: 'bar'};
+ const newManifest = {mediaSequence: 0};
+ const result = mergeManifest(oldManifest, newManifest, ['foo']);
+
+ assert.false(result.updated, 'was not updated');
+ assert.deepEqual(
+ result.manifest,
+ {mediaSequence: 0},
+ 'as expected'
+ );
+ });
+});
+
diff --git a/test/util/deep-equal.test.js b/test/util/deep-equal.test.js
new file mode 100644
index 000000000..88ffdd725
--- /dev/null
+++ b/test/util/deep-equal.test.js
@@ -0,0 +1,47 @@
+import QUnit from 'qunit';
+import deepEqual from '../../src/util/deep-equal.js';
+
+QUnit.module('Deep Equal');
+
+QUnit.test('values', function(assert) {
+ assert.true(deepEqual('a', 'a'));
+ assert.true(deepEqual(1, 1));
+ assert.false(deepEqual({}, null));
+});
+
+QUnit.test('array', function(assert) {
+ assert.true(deepEqual(['a'], ['a']), 'same keys same order equal');
+ assert.false(deepEqual(['a', 'b'], ['b', 'a']), 'different val order');
+ assert.false(deepEqual(['a', 'b', 'c'], ['a', 'b']), 'extra key a');
+ assert.false(deepEqual(['a', 'b'], ['a', 'b', 'c']), 'extra key b');
+});
+
+QUnit.test('object', function(assert) {
+ assert.true(deepEqual({a: 'b'}, {a: 'b'}), 'two objects are equal');
+ assert.false(deepEqual({a: 'b', f: 'a'}, {a: 'b'}), 'extra key a');
+ assert.false(deepEqual({a: 'b'}, {a: 'b', f: 'a'}), 'extra key b');
+});
+
+QUnit.test('complex', function(assert) {
+ assert.true(deepEqual(
+ {a: 5, b: 6, segments: [
+ {uri: 'foo', attributes: {codecs: 'foo'}},
+ {uri: 'bar', attributes: {codecs: 'bar'}}
+ ]},
+ {a: 5, b: 6, segments: [
+ {uri: 'foo', attributes: {codecs: 'foo'}},
+ {uri: 'bar', attributes: {codecs: 'bar'}}
+ ]},
+ ));
+
+ assert.false(deepEqual(
+ {a: 5, b: 6, segments: [
+ {uri: 'foo', attributes: {codecs: 'foo'}},
+ {uri: 'bar', attributes: {codecs: 'bar'}}
+ ]},
+ {a: 5, b: 6, segments: [
+ {uri: 'foo', attributes: {codecs: 'foo'}},
+ {uri: 'jar', attributes: {codecs: 'bar'}}
+ ]},
+ ));
+});