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'}} + ]}, + )); +});