From 5064aabc6a02ad5c8c7c5afcf62c6192cf3b7379 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Thu, 12 Aug 2021 13:09:02 -0400 Subject: [PATCH 01/27] feat: first draft for hls and dash --- src/playlist-loader/TODO.md | 7 + .../dash-main-playlist-loader.js | 123 ++++++++++++++++ .../dash-media-playlist-loader.js | 30 ++++ .../hls-main-playlist-loader.js | 29 ++++ .../hls-media-playlist-loader.js | 130 +++++++++++++++++ src/playlist-loader/media-list.js | 22 +++ src/playlist-loader/playlist-loader.js | 137 ++++++++++++++++++ src/playlist-loader/utils.js | 137 ++++++++++++++++++ 8 files changed, 615 insertions(+) create mode 100644 src/playlist-loader/TODO.md create mode 100644 src/playlist-loader/dash-main-playlist-loader.js create mode 100644 src/playlist-loader/dash-media-playlist-loader.js create mode 100644 src/playlist-loader/hls-main-playlist-loader.js create mode 100644 src/playlist-loader/hls-media-playlist-loader.js create mode 100644 src/playlist-loader/media-list.js create mode 100644 src/playlist-loader/playlist-loader.js create mode 100644 src/playlist-loader/utils.js diff --git a/src/playlist-loader/TODO.md b/src/playlist-loader/TODO.md new file mode 100644 index 000000000..9af7802da --- /dev/null +++ b/src/playlist-loader/TODO.md @@ -0,0 +1,7 @@ +* Finish main/media merge logic with information on what was actually updated passed all the way up. this should be in the form of `{type: 'media', id, uri}` +* Finish DashMainPlaylistLoader + * Finish interaction between main and media playlist loaders + * migrate over sidx logic + * migrate over utc timing logic +* Finish DashMediaPlaylistLoader + * Finish interaction between main and media playlist loaders 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..c2fa5fd8a --- /dev/null +++ b/src/playlist-loader/dash-main-playlist-loader.js @@ -0,0 +1,123 @@ +import PlaylistLoader from './playlist-loader.js'; +import { + parse as parseMpd +// TODO +// addSidxSegmentsToPlaylist, +// generateSidxKey, +// parseUTCTiming +} from 'mpd-parser'; +import {forEachMediaGroup} from './manifest'; + +const findMedia = function(mainManifest, id) { + if (!mainManifest || !mainManifest.playlists || !mainManifest.playlists.length) { + return; + } + for (let i = 0; i < mainManifest.playlists.length; i++) { + const media = mainManifest.playlists[i]; + + if (media.id === id) { + return media; + } + } + + forEachMediaGroup(mainManifest, function(properties, type, group, label) { + + }); +}; + +const mergeMedia = function(oldMedia, newMedia) { + +}; + +const mergeMainManifest = function(oldMain, newMain, sidxMapping) { + const result = { + mergedManifest: newMain, + updated: false + }; + + if (!oldMain) { + return result; + } + + if (oldMain.minimumUpdatePeriod !== newMain.minimumUpdatePeriod) { + result.updated = true; + } + + result.playlists = []; + + // First update the media in playlist array + for (let i = 0; i < newMain.playlists.length; i++) { + const newMedia = newMain.playlists[i]; + const oldMedia = findMedia(oldMain, newMedia.id); + const {updated, mergedMedia} = mergeMedia(oldMedia, newMedia); + + result.mergedManifest.playlists[i] = mergedMedia; + + if (updated) { + result.updated = true; + } + } + + // Then update media group playlists + forEachMediaGroup(newMain, (newProperties, type, group, label) => { + const oldProperties = oldMain.mediaGroups && + oldMain.mediaGroups[type] && oldMain.mediaGroups[type][group] && + oldMain.mergedMedia[type][group][label]; + + // nothing to merge. + if (!oldProperties || !newProperties || !oldProperties.playlists || !newProperties.playlists || !oldProperties.Playlists.length || !newProperties.playlists.length) { + return; + } + + for (let i = 0; i < newProperties.playlists.length; i++) { + const newMedia = newProperties.playlists[i]; + const oldMedia = oldProperties.playlists[i]; + + const {updated, mergedMedia} = mergeMedia(oldMedia, newMedia); + + result.mediaGroups[type][group][label].playlists[i] = mergedMedia; + + if (updated) { + result.updated = true; + } + } + }); + + return result; +}; + +class DashMainPlaylistLoader extends PlaylistLoader { + constructor(uri, options) { + super(uri, options); + this.clientOffset_ = null; + this.sidxMapping_ = null; + this.mediaList_ = options.mediaList; + } + + parseManifest_(oldManifest, manifestString, callback) { + const newManifest = parseMpd(manifestString, { + manifestUri: this.uri_, + clientOffset: this.clientOffset_, + sidxMapping: this.sidxMapping_ + }); + + const {updated, mergedManifest} = mergeMainManifest( + oldManifest, + newManifest, + this.sidxMapping_ + ); + + if (mergedManifest.minimumUpdatePeriod === 0) { + // use media playlist target duration. + // TODO: need a way for the main playlist loader to get the + // target duration of the currently selected + + } else if (typeof mergedManifest.minimumUpdatePeriod === 'number') { + this.mediaUpdateTime_ = mergedManifest.minimumUpdatePeriod; + } + + callback(mergedManifest, updated); + } +} + +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..a8fe20cc1 --- /dev/null +++ b/src/playlist-loader/dash-media-playlist-loader.js @@ -0,0 +1,30 @@ +import PlaylistLoader from './playlist-loader.js'; + +class DashMediaPlaylistLoader extends PlaylistLoader { + constructor(uri, options) { + super(uri, options); + + this.mainPlaylistLoader_ = options.mainPlaylistLoader; + + this.mainPlaylistLoader_.on('updated', (updates) => { + for (let i = 0; i < updates.length; i++) { + if (updates[i].type === 'media' && updates[i].uri === this.uri()) { + this.trigger('updated'); + break; + } + } + }); + } + + manifest() { + return this.mainPlaylistLoader_.getPlaylist(this.uri_); + } + + start() { + if (!this.started_) { + this.started_ = true; + } + } +} + +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..ad702a09e --- /dev/null +++ b/src/playlist-loader/hls-main-playlist-loader.js @@ -0,0 +1,29 @@ +import PlaylistLoader from './playlist-loader.js'; +import {parseManifest} from '../manifest.js'; + +class HlsMainPlaylistLoader extends PlaylistLoader { + parseManifest_(oldManifest, manifestString, callback) { + const newManifest = 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 + }); + + // updated is always true for + callback(newManifest, true); + } + + start() { + // never re-request the manifest. + if (this.manifest_) { + this.started_ = true; + } + + 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..226084828 --- /dev/null +++ b/src/playlist-loader/hls-media-playlist-loader.js @@ -0,0 +1,130 @@ +import PlaylistLoader from './playlist-loader.js'; +import {parseManifest} from '../manifest.js'; +import {mergeOptions} from 'video.js'; +import {mergeSegments} from './utils.js'; + +/** + * Calculates the time to wait before refreshing a live playlist + * + * @param {Object} media + * 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 + */ +const timeBeforeRefresh = function(manifest, update) { + const lastSegment = 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; +}; + +export const getAllSegments = function(manifest) { + const segments = manifest.segments || []; + const 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) { + // 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') { + return segments; + } + } + } + // set the duration for our preload segment to target duration. + preloadSegment.duration = manifest.targetDuration; + preloadSegment.preload = true; + + segments.push(preloadSegment); + } + + return segments; +}; + +const mergeMedia = function(oldMedia, newMedia) { + const result = { + mergedMedia: newMedia, + updated: true + }; + + if (!oldMedia) { + return result; + } + + result.mergedManifest = mergeOptions(oldMedia, newMedia); + + // always use the new manifest's preload segment + if (result.mergedManifest.preloadSegment && !newMedia.preloadSegment) { + delete result.mergedManifest.preloadSegment; + } + + newMedia.segments = getAllSegments(newMedia); + + if (newMedia.skip) { + newMedia.segments = newMedia.segments || []; + // add back in objects for skipped segments, so that we merge + // old properties into the new segments + for (let i = 0; i < newMedia.skip.skippedSegments; i++) { + newMedia.segments.unshift({skipped: true}); + } + } + + // if the update could overlap existing segment information, merge the two segment lists + const {updated, mergedSegments} = mergeSegments(oldMedia, newMedia); + + if (updated) { + result.updated = true; + } + + result.mergedManifest.segments = mergedSegments; + + return result; +}; + +class HlsMediaPlaylistLoader extends PlaylistLoader { + + parseManifest_(oldMedia, manifestString, callback) { + const newMedia = 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 {updated, mergedMedia} = mergeMedia(oldMedia, newMedia); + + callback(mergedMedia, updated); + } + + getMediaRefreshTime_(updated) { + return timeBeforeRefresh(this.manifest(), 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; + } + + super.start(); + } +} + +export default HlsMediaPlaylistLoader; diff --git a/src/playlist-loader/media-list.js b/src/playlist-loader/media-list.js new file mode 100644 index 000000000..4d16284f8 --- /dev/null +++ b/src/playlist-loader/media-list.js @@ -0,0 +1,22 @@ +import videojs from 'video.js'; + +class MediaList extends videojs.EventTarget { + init(playlistLoaders) { + playlistLoaders.forEach((playlistLoader) => { + this.add(playlistLoader); + }); + } + + add(playlistLoader) { + + } + + remove(playlistLoader) { + + } + + dispose() { + } +} + +export default MediaList; diff --git a/src/playlist-loader/playlist-loader.js b/src/playlist-loader/playlist-loader.js new file mode 100644 index 000000000..cd03e59f5 --- /dev/null +++ b/src/playlist-loader/playlist-loader.js @@ -0,0 +1,137 @@ +import videojs from 'video.js'; +import logger from '../util/logger'; +import window from 'global/window'; + +class PlaylistLoader extends videojs.EventTarget { + constructor(uri, options = {}) { + this.logger_ = logger(this.constructor.name); + this.uri_ = uri; + this.options_ = options; + this.manifest_ = options.manifest || null; + this.mediaRefreshTimeout_ = null; + this.request_ = null; + this.started_ = false; + + this.on('refresh', this.refreshManifest); + this.on('updated', this.setMediaUpdateTimeout_); + } + + uri() { + return this.uri_; + } + + manifest() { + return this.manifest_; + } + + started() { + return this.started_; + } + + refreshManifest(callback) { + this.makeRequest({uri: this.uri_}, (request, wasRedirected) => { + if (wasRedirected) { + this.uri_ = request.responseURL; + } + + this.parseManifest_(this.manifest_, request.responseText, (newManifest, wasUpdated) => { + wasUpdated = wasUpdated || !this.manifest_; + this.manifest_ = newManifest; + if (wasUpdated) { + this.trigger('updated'); + } + }); + }); + } + + parseManifest_(manifestText, callback) { + return null; + } + + // make a request and do custom error handling + makeRequest(options, callback) { + const xhrOptions = videojs.mergeOptions({withCredentials: this.options_.withCredentials}, options); + + this.request_ = this.options_.vhs.xhr(xhrOptions, (error, request) => { + // disposed + if (!this.request_) { + return; + } + + // successful or errored requests are finished. + this.request_ = null; + + if (error) { + this.error = typeof error === 'object' && !(error instanceof Error) ? error : { + status: request.status, + message: `Playlist request error at URI ${request.uri}`, + response: request.response, + code: (request.status >= 500) ? 4 : 2 + }; + + this.trigger('error'); + return; + } + + const wasRedirected = + this.options_.handleManifestRedirects && + request.responseURL !== xhrOptions.uri; + + callback(request, wasRedirected); + }); + } + + start() { + if (!this.started_) { + this.started_ = true; + this.refreshManifest(); + } + } + + stop() { + if (this.started_) { + this.started_ = false; + this.stopRequest(); + this.clearMediaRefreshTimeout_(); + } + } + + // stop a request if one exists. + stopRequest() { + if (this.request_) { + this.request_.onreadystatechange = null; + this.request_.abort(); + this.request_ = null; + } + } + + clearMediaRefreshTimeout_() { + if (this.mediaRefreshTimeout_) { + window.clearTimeout(this.mediaRefreshTimeout_); + this.mediaRefreshTimeout_ = null; + } + } + + setMediaRefreshTimeout_(event) { + this.clearMediaRefreshTimeout_(); + const time = this.getMediaRefreshTime_(event && event.type === 'updated'); + + if (typeof time !== 'number') { + return; + } + + this.refreshTimeout_ = window.setTimout(() => { + this.refreshTimeout_ = null; + this.trigger('refresh'); + }, time); + } + + getMediaRefreshTime_() {} + + dispose() { + 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..b340b675a --- /dev/null +++ b/src/playlist-loader/utils.js @@ -0,0 +1,137 @@ +import {mergeOptions} from 'video.js'; +import {resolveUrl} from './resolve-url'; + +export const isMediaUnchanged = (a, b) => a === b || + (a.segments && b.segments && a.segments.length === b.segments.length && + a.endList === b.endList && + a.mediaSequence === b.mediaSequence && + (a.preloadSegment && b.preloadSegment && a.preloadSegment === b.preloadSegment)); + +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 + */ +const mergeSegment = function(a, b, baseUri) { + const result = { + mergedSegment: b, + updated: false + }; + + if (!a) { + return b; + } + + result.mergedSegment = 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) { + delete result.preloadHints; + } + + // if only the old segment has parts + // then the parts are no longer valid + if (a.parts && !b.parts) { + delete result.parts; + // if both segments have parts + // copy part propeties from the old segment + // to the new one. + } else if (a.parts && b.parts) { + for (let i = 0; i < b.parts.length; i++) { + if (a.parts && a.parts[i]) { + result.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) { + result.skipped = false; + } + + // set preload to false for segments that have + // had information added in the new segment. + if (a.preload && !b.preload) { + result.preload = false; + } + + return result; +}; + +export const mergeSegments = function({oldSegments, newSegments, offset = 0, baseUri}) { + const result = { + mergedSegments: newSegments, + updated: false + }; + + if (!oldSegments || !oldSegments.length) { + return result; + } + + let currentMap; + + for (let newIndex = 0; newIndex < newSegments.length; newIndex++) { + const oldSegment = oldSegments[newIndex + offset]; + const newSegment = newSegments[newIndex]; + let mergedSegment; + + if (oldSegment) { + currentMap = oldSegment.map || currentMap; + + mergedSegment = mergeSegment(oldSegment, newSegment); + } else { + // carry over map to new segment if it is missing + if (currentMap && !newSegment.map) { + newSegment.map = currentMap; + } + + mergedSegment = newSegment; + } + + result.mergedSegments.push(resolveSegmentUris(mergedSegment, baseUri)); + } + return result; +}; From 5ee1bda80dbb9d0f365e8188dbb12e4291f7e17f Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Fri, 24 Sep 2021 14:15:17 -0400 Subject: [PATCH 02/27] more work and some tests --- src/playlist-loader/TODO.md | 2 - .../dash-main-playlist-loader.js | 134 +++++++++++---- .../dash-media-playlist-loader.js | 49 +++++- .../hls-main-playlist-loader.js | 7 +- .../hls-media-playlist-loader.js | 13 +- src/playlist-loader/media-list.js | 22 --- src/playlist-loader/playlist-loader.js | 54 ++++-- src/playlist-loader/utils.js | 31 ++++ test/playlist-loader/playlist-loader.test.js | 161 ++++++++++++++++++ 9 files changed, 378 insertions(+), 95 deletions(-) delete mode 100644 src/playlist-loader/media-list.js create mode 100644 test/playlist-loader/playlist-loader.test.js diff --git a/src/playlist-loader/TODO.md b/src/playlist-loader/TODO.md index 9af7802da..51472c90f 100644 --- a/src/playlist-loader/TODO.md +++ b/src/playlist-loader/TODO.md @@ -1,7 +1,5 @@ -* Finish main/media merge logic with information on what was actually updated passed all the way up. this should be in the form of `{type: 'media', id, uri}` * Finish DashMainPlaylistLoader * Finish interaction between main and media playlist loaders * migrate over sidx logic - * migrate over utc timing logic * Finish DashMediaPlaylistLoader * Finish interaction between main and media playlist loaders diff --git a/src/playlist-loader/dash-main-playlist-loader.js b/src/playlist-loader/dash-main-playlist-loader.js index c2fa5fd8a..93dc1d857 100644 --- a/src/playlist-loader/dash-main-playlist-loader.js +++ b/src/playlist-loader/dash-main-playlist-loader.js @@ -1,14 +1,15 @@ import PlaylistLoader from './playlist-loader.js'; +import {resolveUrl} from './resolve-url'; import { - parse as parseMpd + parse as parseMpd, + parseUTCTiming // TODO // addSidxSegmentsToPlaylist, // generateSidxKey, -// parseUTCTiming } from 'mpd-parser'; -import {forEachMediaGroup} from './manifest'; +import {forEachMediaGroup} from './utils.js'; -const findMedia = function(mainManifest, id) { +export const findMedia = function(mainManifest, id) { if (!mainManifest || !mainManifest.playlists || !mainManifest.playlists.length) { return; } @@ -20,9 +21,24 @@ const findMedia = function(mainManifest, id) { } } + let foundMedia; + forEachMediaGroup(mainManifest, function(properties, type, group, label) { + if (!properties.playlists) { + return; + } + + for (let i = 0; i < properties.playlists; i++) { + const media = mainManifest.playlists[i]; + if (media.id === id) { + foundMedia = media; + return true; + } + } }); + + return foundMedia; }; const mergeMedia = function(oldMedia, newMedia) { @@ -30,19 +46,12 @@ const mergeMedia = function(oldMedia, newMedia) { }; const mergeMainManifest = function(oldMain, newMain, sidxMapping) { - const result = { - mergedManifest: newMain, - updated: false - }; + const result = newMain; if (!oldMain) { return result; } - if (oldMain.minimumUpdatePeriod !== newMain.minimumUpdatePeriod) { - result.updated = true; - } - result.playlists = []; // First update the media in playlist array @@ -72,14 +81,9 @@ const mergeMainManifest = function(oldMain, newMain, sidxMapping) { for (let i = 0; i < newProperties.playlists.length; i++) { const newMedia = newProperties.playlists[i]; const oldMedia = oldProperties.playlists[i]; - - const {updated, mergedMedia} = mergeMedia(oldMedia, newMedia); + const mergedMedia = mergeMedia(oldMedia, newMedia); result.mediaGroups[type][group][label].playlists[i] = mergedMedia; - - if (updated) { - result.updated = true; - } } }); @@ -92,32 +96,92 @@ class DashMainPlaylistLoader extends PlaylistLoader { this.clientOffset_ = null; this.sidxMapping_ = null; this.mediaList_ = options.mediaList; + this.clientClockOffset_ = null; + this.setMediaRefreshTimeout_ = this.setMediaRefreshTimeout_.bind(this); + } + + parseManifest_(manifestString, callback) { + this.syncClientServerClock_(manifestString, function(clientOffset) { + const parsedManifest = parseMpd(manifestString, { + manifestUri: this.uri_, + clientOffset, + sidxMapping: this.sidxMapping_ + }); + + const mergedManifest = mergeMainManifest( + this.manifest_, + parsedManifest, + this.sidxMapping_ + ); + + callback(mergedManifest); + }); } - parseManifest_(oldManifest, manifestString, callback) { - const newManifest = parseMpd(manifestString, { - manifestUri: this.uri_, - clientOffset: this.clientOffset_, - sidxMapping: this.sidxMapping_ + syncClientServerClock_(manifestString, callback) { + const utcTiming = parseUTCTiming(manifestString); + + // 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 + }, function(request) { + let serverTime; + + if (utcTiming.method === 'HEAD') { + if (!request.responseHeaders || !request.responseHeaders.date) { + // expected date header not preset, fall back to using date header from mpd + this.logger_('warning expected date header from mpd not present, using mpd request time.'); + serverTime = this.lastRequestTime(); + } else { + serverTime = Date.parse(request.responseHeaders.date); + } + } else { + serverTime = Date.parse(request.responseText); + } + + callback(serverTime - Date.now()); }); + } - const {updated, mergedManifest} = mergeMainManifest( - oldManifest, - newManifest, - this.sidxMapping_ - ); + setMediaRefreshTime_(time) { + if (!this.getMediaRefreshTime_()) { + this.setMediaRefreshTimeout_(time); + } + } - if (mergedManifest.minimumUpdatePeriod === 0) { - // use media playlist target duration. - // TODO: need a way for the main playlist loader to get the - // target duration of the currently selected + getMediaRefreshTime_() { + const minimumUpdatePeriod = this.manifest_.minimumUpdatePeriod; - } else if (typeof mergedManifest.minimumUpdatePeriod === 'number') { - this.mediaUpdateTime_ = mergedManifest.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; } - callback(mergedManifest, updated); + // 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. + if (minimumUpdatePeriod === 0) { + return; + } + + 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 index a8fe20cc1..99445097b 100644 --- a/src/playlist-loader/dash-media-playlist-loader.js +++ b/src/playlist-loader/dash-media-playlist-loader.js @@ -1,23 +1,50 @@ import PlaylistLoader from './playlist-loader.js'; +import {findMedia} from './dash-main-playlist-loader.js'; + +const findManifestString = function(manifestString, id) { + +}; + +const wasMediaUpdated = function(oldManifest, newManifest) { + +}; class DashMediaPlaylistLoader extends PlaylistLoader { constructor(uri, options) { super(uri, options); this.mainPlaylistLoader_ = options.mainPlaylistLoader; + this.onMainUpdated_ = this.onMainUpdated_.bind(this); - this.mainPlaylistLoader_.on('updated', (updates) => { - for (let i = 0; i < updates.length; i++) { - if (updates[i].type === 'media' && updates[i].uri === this.uri()) { - this.trigger('updated'); - break; - } - } - }); + this.mainPlaylistLoader_.on('updated', this.onMainUpdated_); + } + + onMainUpdated_() { + const oldManifestString = this.manifestString_; + const oldManifest = this.manifest_; + + this.manifestString_ = findManifestString( + this.mainPlaylistLoader_.manifestString(), + this.uri() + ); + + this.manifest_ = findMedia( + this.mainPlaylistLoader_.manifest(), + this.uri() + ); + + const wasUpdated = !oldManifestString || + this.manifestString_ !== oldManifestString || + wasMediaUpdated(oldManifest, this.manifest_); + + if (wasUpdated) { + this.trigger('updated'); + this.mainPlaylistLoader_.setMediaRefreshTime_(this.manifest().targetDuration * 1000); + } } manifest() { - return this.mainPlaylistLoader_.getPlaylist(this.uri_); + return findMedia(this.mainPlaylistLoader_.manifest(), this.uri()); } start() { @@ -25,6 +52,10 @@ class DashMediaPlaylistLoader extends PlaylistLoader { this.started_ = true; } } + + dispose() { + 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 index ad702a09e..c14e99d96 100644 --- a/src/playlist-loader/hls-main-playlist-loader.js +++ b/src/playlist-loader/hls-main-playlist-loader.js @@ -2,8 +2,8 @@ import PlaylistLoader from './playlist-loader.js'; import {parseManifest} from '../manifest.js'; class HlsMainPlaylistLoader extends PlaylistLoader { - parseManifest_(oldManifest, manifestString, callback) { - const newManifest = parseManifest({ + 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, @@ -12,8 +12,7 @@ class HlsMainPlaylistLoader extends PlaylistLoader { experimentalLLHLS: this.options_.experimentalLLHLS }); - // updated is always true for - callback(newManifest, true); + callback(parsedManifest); } start() { diff --git a/src/playlist-loader/hls-media-playlist-loader.js b/src/playlist-loader/hls-media-playlist-loader.js index 226084828..0e542eee9 100644 --- a/src/playlist-loader/hls-media-playlist-loader.js +++ b/src/playlist-loader/hls-media-playlist-loader.js @@ -97,8 +97,8 @@ const mergeMedia = function(oldMedia, newMedia) { class HlsMediaPlaylistLoader extends PlaylistLoader { - parseManifest_(oldMedia, manifestString, callback) { - const newMedia = parseManifest({ + 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, @@ -106,14 +106,11 @@ class HlsMediaPlaylistLoader extends PlaylistLoader { customTagMappers: this.options_.customTagMappers, experimentalLLHLS: this.options_.experimentalLLHLS }); + const updated = true; - const {updated, mergedMedia} = mergeMedia(oldMedia, newMedia); + this.mediaRefreshTime_ = timeBeforeRefresh(this.manifest(), updated); - callback(mergedMedia, updated); - } - - getMediaRefreshTime_(updated) { - return timeBeforeRefresh(this.manifest(), updated); + callback(mergeMedia(this.manifest_, parsedMedia), updated); } start() { diff --git a/src/playlist-loader/media-list.js b/src/playlist-loader/media-list.js deleted file mode 100644 index 4d16284f8..000000000 --- a/src/playlist-loader/media-list.js +++ /dev/null @@ -1,22 +0,0 @@ -import videojs from 'video.js'; - -class MediaList extends videojs.EventTarget { - init(playlistLoaders) { - playlistLoaders.forEach((playlistLoader) => { - this.add(playlistLoader); - }); - } - - add(playlistLoader) { - - } - - remove(playlistLoader) { - - } - - dispose() { - } -} - -export default MediaList; diff --git a/src/playlist-loader/playlist-loader.js b/src/playlist-loader/playlist-loader.js index cd03e59f5..28462ca73 100644 --- a/src/playlist-loader/playlist-loader.js +++ b/src/playlist-loader/playlist-loader.js @@ -4,16 +4,25 @@ import window from 'global/window'; class PlaylistLoader extends videojs.EventTarget { constructor(uri, options = {}) { + super(); this.logger_ = logger(this.constructor.name); 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.setMediaUpdateTimeout_); + this.on('updated', this.setMediaRefreshTimeout_); + } + + request() { + return this.request_; } uri() { @@ -24,37 +33,49 @@ class PlaylistLoader extends videojs.EventTarget { return this.manifest_; } + manifestString() { + return this.manifestString_; + } + started() { return this.started_; } + lastRequestTime() { + return this.lastRequestTime_; + } + refreshManifest(callback) { - this.makeRequest({uri: this.uri_}, (request, wasRedirected) => { + this.makeRequest({uri: this.uri()}, (request, wasRedirected) => { if (wasRedirected) { this.uri_ = request.responseURL; } - this.parseManifest_(this.manifest_, request.responseText, (newManifest, wasUpdated) => { - wasUpdated = wasUpdated || !this.manifest_; - this.manifest_ = newManifest; - if (wasUpdated) { + 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'); } }); }); } - parseManifest_(manifestText, callback) { - return null; - } + parseManifest_(manifestText, callback) {} // make a request and do custom error handling - makeRequest(options, callback) { + makeRequest(options, callback, handleErrors = true) { const xhrOptions = videojs.mergeOptions({withCredentials: this.options_.withCredentials}, options); this.request_ = this.options_.vhs.xhr(xhrOptions, (error, request) => { // disposed - if (!this.request_) { + if (this.isDisposed_) { return; } @@ -112,9 +133,8 @@ class PlaylistLoader extends videojs.EventTarget { } } - setMediaRefreshTimeout_(event) { + setMediaRefreshTimeout_(time = this.getMediaRefreshTime_()) { this.clearMediaRefreshTimeout_(); - const time = this.getMediaRefreshTime_(event && event.type === 'updated'); if (typeof time !== 'number') { return; @@ -123,12 +143,16 @@ class PlaylistLoader extends videojs.EventTarget { this.refreshTimeout_ = window.setTimout(() => { this.refreshTimeout_ = null; this.trigger('refresh'); + this.setMediaRefreshTimeout_(); }, time); } - getMediaRefreshTime_() {} + getMediaRefreshTime_() { + return this.mediaRefreshTime_; + } dispose() { + this.isDisposed_ = true; this.stop(); this.trigger('dispose'); } diff --git a/src/playlist-loader/utils.js b/src/playlist-loader/utils.js index b340b675a..84bb0a548 100644 --- a/src/playlist-loader/utils.js +++ b/src/playlist-loader/utils.js @@ -135,3 +135,34 @@ export const mergeSegments = function({oldSegments, newSegments, offset = 0, bas } return result; }; + +/** + * Loops through all supported media groups in master and calls the provided + * callback for each group. Unless true is returned from the callback. + * + * @param {Object} master + * The parsed master manifest object + * @param {Function} callback + * Callback to call for each media group + */ +export const forEachMediaGroup = (master, callback) => { + if (!master.mediaGroups) { + return; + } + ['AUDIO', 'SUBTITLES'].forEach((mediaType) => { + if (!master.mediaGroups[mediaType]) { + return; + } + for (const groupKey in master.mediaGroups[mediaType]) { + for (const labelKey in master.mediaGroups[mediaType][groupKey]) { + const mediaProperties = master.mediaGroups[mediaType][groupKey][labelKey]; + + const stop = callback(mediaProperties, mediaType, groupKey, labelKey); + + if (stop) { + return; + } + } + } + }); +}; diff --git a/test/playlist-loader/playlist-loader.test.js b/test/playlist-loader/playlist-loader.test.js new file mode 100644 index 000000000..e20483b9b --- /dev/null +++ b/test/playlist-loader/playlist-loader.test.js @@ -0,0 +1,161 @@ +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) { + 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'; + const loader = new PlaylistLoader('foo.uri', { + vhs: this.fakeVhs, + manifest, + manifestString, + lastRequestTime + }); + + assert.equal(loader.uri(), 'foo.uri', 'uri set'); + assert.equal(loader.manifest(), manifest, 'manifest set'); + assert.equal(loader.manifestString(), manifestString, 'manifestString set'); + assert.equal(loader.started(), false, 'not started'); + assert.equal(loader.request(), null, 'no request'); + assert.equal(loader.lastRequestTime(), lastRequestTime, 'last request time saved'); + assert.equal(loader.getMediaRefreshTime_(), null, 'no media refresh time'); + + loader.logger_('foo'); + + assert.equal(this.logLines[0], 'VHS: PlaylistLoader > foo', 'logger logs as expected'); + }); + + QUnit.module('#start()'); + QUnit.test('sets started to true', function(assert) { + const loader = new PlaylistLoader('foo.uri', {vhs: this.fakeVhs}); + + assert.equal(this.requests.length, 0, 'no requests'); + + loader.start(); + + assert.equal(loader.started(), true, 'is started'); + }); + + QUnit.test('does not request until start', function(assert) { + const loader = new PlaylistLoader('foo.uri', {vhs: this.fakeVhs}); + + assert.equal(this.requests.length, 0, 'no requests'); + + loader.start(); + + assert.equal(this.requests.length, 1, 'one request'); + }); + + QUnit.test('requests relative uri', function(assert) { + const loader = new PlaylistLoader('foo.uri', {vhs: this.fakeVhs}); + + assert.equal(this.requests.length, 0, 'no requests'); + + 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) { + const loader = new PlaylistLoader(urlTo('foo.uri'), {vhs: this.fakeVhs}); + + assert.equal(this.requests.length, 0, 'no requests'); + + 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) { + const loader = new PlaylistLoader('foo.uri', { + vhs: this.fakeVhs, + handleManifestRedirects: true + }); + + loader.refreshManifest(); + + this.requests[0].respond(200, null, 'foo'); + + assert.equal(loader.uri(), urlTo('foo.uri'), 'redirected to absolute'); + }); + + QUnit.test('sets lastRequestTime to now after request', function(assert) { + const loader = new PlaylistLoader('foo.uri', { + vhs: this.fakeVhs + }); + + loader.refreshManifest(); + + this.requests[0].respond(200, null, 'foo'); + + assert.equal(loader.lastRequestTime(), 0, 'set last request time'); + }); + + QUnit.test('sets lastRequestTime to date header after request', function(assert) { + this.clock.restore(); + + const loader = new PlaylistLoader('foo.uri', { + vhs: this.fakeVhs + }); + + loader.refreshManifest(); + + const date = new Date(); + + this.requests[0].respond(200, {date: date.toString()}, 'foo'); + + assert.equal(loader.lastRequestTime(), Date.parse(date.toString()), 'set last request time'); + }); + + QUnit.test('lastRequestTime to date header after request', function(assert) { + this.clock.restore(); + + const loader = new PlaylistLoader('foo.uri', { + vhs: this.fakeVhs + }); + + loader.refreshManifest(); + + const date = new Date(); + + this.requests[0].respond(200, {date: date.toString()}, 'foo'); + + assert.equal(loader.lastRequestTime(), Date.parse(date.toString()), 'set last request time'); + }); + + // TODO: parseManifest + // TODO: makeRequest + // TODO: stopRequest + // TODO: stop + // TODO: set/clear timeout + // TODO: dispose + // TODO: events +}); + From 381b73d5363bf01ff5d7687b6527228e2edbd177 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Wed, 29 Sep 2021 13:06:36 -0400 Subject: [PATCH 03/27] fully tested the base class --- src/playlist-loader/playlist-loader.js | 40 +- test/playlist-loader/playlist-loader.test.js | 521 +++++++++++++++++-- 2 files changed, 503 insertions(+), 58 deletions(-) diff --git a/src/playlist-loader/playlist-loader.js b/src/playlist-loader/playlist-loader.js index 28462ca73..a3cdfa898 100644 --- a/src/playlist-loader/playlist-loader.js +++ b/src/playlist-loader/playlist-loader.js @@ -17,10 +17,14 @@ class PlaylistLoader extends videojs.EventTarget { this.mediaRefreshTimeout_ = null; this.request_ = null; this.started_ = false; - this.on('refresh', this.refreshManifest); + this.on('refresh', this.refreshManifest_); this.on('updated', this.setMediaRefreshTimeout_); } + error() { + return this.error_; + } + request() { return this.request_; } @@ -45,8 +49,8 @@ class PlaylistLoader extends videojs.EventTarget { return this.lastRequestTime_; } - refreshManifest(callback) { - this.makeRequest({uri: this.uri()}, (request, wasRedirected) => { + refreshManifest_(callback) { + this.makeRequest_({uri: this.uri()}, (request, wasRedirected) => { if (wasRedirected) { this.uri_ = request.responseURL; } @@ -70,7 +74,12 @@ class PlaylistLoader extends videojs.EventTarget { parseManifest_(manifestText, callback) {} // make a request and do custom error handling - makeRequest(options, callback, handleErrors = true) { + makeRequest_(options, callback, handleErrors = true) { + if (!this.started_) { + this.error_ = {message: 'makeRequest_ cannot be called before started!'}; + this.trigger('error'); + return; + } const xhrOptions = videojs.mergeOptions({withCredentials: this.options_.withCredentials}, options); this.request_ = this.options_.vhs.xhr(xhrOptions, (error, request) => { @@ -83,7 +92,7 @@ class PlaylistLoader extends videojs.EventTarget { this.request_ = null; if (error) { - this.error = typeof error === 'object' && !(error instanceof Error) ? error : { + this.error_ = typeof error === 'object' && !(error instanceof Error) ? error : { status: request.status, message: `Playlist request error at URI ${request.uri}`, response: request.response, @@ -94,9 +103,8 @@ class PlaylistLoader extends videojs.EventTarget { return; } - const wasRedirected = - this.options_.handleManifestRedirects && - request.responseURL !== xhrOptions.uri; + const wasRedirected = Boolean(this.options_.handleManifestRedirects && + request.responseURL !== xhrOptions.uri); callback(request, wasRedirected); }); @@ -105,7 +113,7 @@ class PlaylistLoader extends videojs.EventTarget { start() { if (!this.started_) { this.started_ = true; - this.refreshManifest(); + this.refreshManifest_(); } } @@ -127,20 +135,24 @@ class PlaylistLoader extends videojs.EventTarget { } clearMediaRefreshTimeout_() { - if (this.mediaRefreshTimeout_) { - window.clearTimeout(this.mediaRefreshTimeout_); - this.mediaRefreshTimeout_ = null; + if (this.refreshTimeout_) { + window.clearTimeout(this.refreshTimeout_); + this.refreshTimeout_ = null; } } - setMediaRefreshTimeout_(time = this.getMediaRefreshTime_()) { + setMediaRefreshTimeout_(time) { + if (typeof time !== 'number') { + 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.setTimout(() => { + this.refreshTimeout_ = window.setTimeout(() => { this.refreshTimeout_ = null; this.trigger('refresh'); this.setMediaRefreshTimeout_(); diff --git a/test/playlist-loader/playlist-loader.test.js b/test/playlist-loader/playlist-loader.test.js index e20483b9b..197d6d979 100644 --- a/test/playlist-loader/playlist-loader.test.js +++ b/test/playlist-loader/playlist-loader.test.js @@ -19,6 +19,9 @@ QUnit.module('New Playlist Loader', function(hooks) { }; }); hooks.afterEach(function(assert) { + if (this.loader) { + this.loader.dispose(); + } this.env.restore(); videojs.log.debug = this.oldDebugLog; }); @@ -26,136 +29,566 @@ QUnit.module('New Playlist Loader', function(hooks) { 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'; - const loader = new PlaylistLoader('foo.uri', { + + this.loader = new PlaylistLoader('foo.uri', { vhs: this.fakeVhs, manifest, manifestString, lastRequestTime }); - assert.equal(loader.uri(), 'foo.uri', 'uri set'); - assert.equal(loader.manifest(), manifest, 'manifest set'); - assert.equal(loader.manifestString(), manifestString, 'manifestString set'); - assert.equal(loader.started(), false, 'not started'); - assert.equal(loader.request(), null, 'no request'); - assert.equal(loader.lastRequestTime(), lastRequestTime, 'last request time saved'); - assert.equal(loader.getMediaRefreshTime_(), null, 'no media refresh time'); + 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'); - loader.logger_('foo'); + this.loader.logger_('foo'); assert.equal(this.logLines[0], 'VHS: PlaylistLoader > foo', 'logger logs as expected'); }); QUnit.module('#start()'); QUnit.test('sets started to true', function(assert) { - const loader = new PlaylistLoader('foo.uri', {vhs: this.fakeVhs}); + this.loader = new PlaylistLoader('foo.uri', {vhs: this.fakeVhs}); assert.equal(this.requests.length, 0, 'no requests'); - loader.start(); + this.loader.start(); - assert.equal(loader.started(), true, 'is started'); + assert.equal(this.loader.started(), true, 'is started'); }); QUnit.test('does not request until start', function(assert) { - const loader = new PlaylistLoader('foo.uri', {vhs: this.fakeVhs}); + this.loader = new PlaylistLoader('foo.uri', {vhs: this.fakeVhs}); assert.equal(this.requests.length, 0, 'no requests'); - loader.start(); + this.loader.start(); assert.equal(this.requests.length, 1, 'one request'); }); QUnit.test('requests relative uri', function(assert) { - const loader = new PlaylistLoader('foo.uri', {vhs: this.fakeVhs}); + this.loader = new PlaylistLoader('foo.uri', {vhs: this.fakeVhs}); assert.equal(this.requests.length, 0, 'no requests'); - loader.start(); + 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) { - const loader = new PlaylistLoader(urlTo('foo.uri'), {vhs: this.fakeVhs}); + this.loader = new PlaylistLoader(urlTo('foo.uri'), {vhs: this.fakeVhs}); assert.equal(this.requests.length, 0, 'no requests'); - loader.start(); + 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.module('#refreshManifest_()'); + QUnit.test('updates uri() with handleManifestRedirects', function(assert) { - const loader = new PlaylistLoader('foo.uri', { + this.loader = new PlaylistLoader('foo.uri', { vhs: this.fakeVhs, handleManifestRedirects: true }); - loader.refreshManifest(); + this.loader.started_ = true; + this.loader.refreshManifest_(); this.requests[0].respond(200, null, 'foo'); - assert.equal(loader.uri(), urlTo('foo.uri'), 'redirected to absolute'); + 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) { - const loader = new PlaylistLoader('foo.uri', { + this.loader = new PlaylistLoader('foo.uri', { vhs: this.fakeVhs }); - loader.refreshManifest(); + this.loader.started_ = true; + this.loader.refreshManifest_(); this.requests[0].respond(200, null, 'foo'); - assert.equal(loader.lastRequestTime(), 0, 'set last request time'); + assert.equal(this.loader.lastRequestTime(), 0, 'set last request time'); }); - QUnit.test('sets lastRequestTime to date header after request', function(assert) { + QUnit.test('sets lastRequestTime set to date header after request', function(assert) { this.clock.restore(); - const loader = new PlaylistLoader('foo.uri', { + this.loader = new PlaylistLoader('foo.uri', { vhs: this.fakeVhs }); - loader.refreshManifest(); + this.loader.started_ = true; + this.loader.refreshManifest_(); const date = new Date(); this.requests[0].respond(200, {date: date.toString()}, 'foo'); - assert.equal(loader.lastRequestTime(), Date.parse(date.toString()), 'set last request time'); + assert.equal(this.loader.lastRequestTime(), Date.parse(date.toString()), 'set last request time'); }); - QUnit.test('lastRequestTime to date header after request', function(assert) { - this.clock.restore(); + 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); - const loader = new PlaylistLoader('foo.uri', { + 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 }); - loader.refreshManifest(); + const manifest = {foo: 'bar'}; + const manifestString = '{foo: "bar"}'; + let updatedCalled = false; - const date = new Date(); + this.loader.on('updated', function() { + updatedCalled = true; + }); - this.requests[0].respond(200, {date: date.toString()}, 'foo'); + 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 = { + code: 4, + message: 'Playlist request error at URI bar.uri', + response: 'bad request foo bar', + status: 505 + }; + + 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 = { + code: 2, + message: 'Playlist request error at URI bar.uri', + response: 'bad request foo bar', + status: 404 + }; + + assert.deepEqual(this.loader.error(), expectedError, 'expected error'); + assert.equal(this.loader.request(), null, 'no request'); + assert.true(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.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(loader.lastRequestTime(), Date.parse(date.toString()), 'set last request time'); + assert.equal(this.loader.refreshTimeout_, null, 'refreshTimeout_ removed'); + this.clock.tick(20); + assert.false(refreshTriggered, 'refresh not triggered as timeout was cleared'); }); - // TODO: parseManifest - // TODO: makeRequest - // TODO: stopRequest - // TODO: stop - // TODO: set/clear timeout - // TODO: dispose - // TODO: events }); From 6ea4dfe70ad97303ae02c336f7e82bb4775990ae Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Wed, 29 Sep 2021 13:24:00 -0400 Subject: [PATCH 04/27] add tests for HlsMainPlaylistLoader --- .../hls-main-playlist-loader.js | 4 +- .../hls-main-playlist-loader.test.js | 131 ++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 test/playlist-loader/hls-main-playlist-loader.test.js diff --git a/src/playlist-loader/hls-main-playlist-loader.js b/src/playlist-loader/hls-main-playlist-loader.js index c14e99d96..96b4f056f 100644 --- a/src/playlist-loader/hls-main-playlist-loader.js +++ b/src/playlist-loader/hls-main-playlist-loader.js @@ -12,13 +12,15 @@ class HlsMainPlaylistLoader extends PlaylistLoader { experimentalLLHLS: this.options_.experimentalLLHLS }); - callback(parsedManifest); + callback(parsedManifest, this.manifestString_ !== manifestString); } start() { // never re-request the manifest. if (this.manifest_) { + // TODO: we may have to trigger updated here this.started_ = true; + return; } super.start(); 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..73272e909 --- /dev/null +++ b/test/playlist-loader/hls-main-playlist-loader.test.js @@ -0,0 +1,131 @@ +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'); + }); +}); + From 66b72880a19798db45a8f8f83dcfd444a120fa58 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Wed, 29 Sep 2021 15:24:18 -0400 Subject: [PATCH 05/27] remove manifestString function --- src/playlist-loader/playlist-loader.js | 4 ---- test/playlist-loader/playlist-loader.test.js | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/playlist-loader/playlist-loader.js b/src/playlist-loader/playlist-loader.js index a3cdfa898..7d063aedd 100644 --- a/src/playlist-loader/playlist-loader.js +++ b/src/playlist-loader/playlist-loader.js @@ -37,10 +37,6 @@ class PlaylistLoader extends videojs.EventTarget { return this.manifest_; } - manifestString() { - return this.manifestString_; - } - started() { return this.started_; } diff --git a/test/playlist-loader/playlist-loader.test.js b/test/playlist-loader/playlist-loader.test.js index 197d6d979..cda50e594 100644 --- a/test/playlist-loader/playlist-loader.test.js +++ b/test/playlist-loader/playlist-loader.test.js @@ -42,7 +42,7 @@ QUnit.module('New Playlist Loader', function(hooks) { 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.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'); @@ -200,7 +200,7 @@ QUnit.module('New Playlist Loader', function(hooks) { 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.equal(this.loader.manifestString_, manifestString, 'manifestString added to loader'); assert.true(updatedCalled, 'updated was called'); }); @@ -229,7 +229,7 @@ QUnit.module('New Playlist Loader', function(hooks) { 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.equal(this.loader.manifestString_, null, 'manifestString not added to loader'); assert.false(updatedCalled, 'updated was not called'); }); From 349ae74d8fe3adf348dd5e47503c3631b908c06b Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Wed, 29 Sep 2021 15:38:43 -0400 Subject: [PATCH 06/27] test some functions that i missed --- src/playlist-loader/playlist-loader.js | 4 ++ test/playlist-loader/playlist-loader.test.js | 57 ++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/playlist-loader/playlist-loader.js b/src/playlist-loader/playlist-loader.js index 7d063aedd..8bb5b35bc 100644 --- a/src/playlist-loader/playlist-loader.js +++ b/src/playlist-loader/playlist-loader.js @@ -138,6 +138,10 @@ class PlaylistLoader extends videojs.EventTarget { } setMediaRefreshTimeout_(time) { + // do nothing if disposed + if (this.isDisposed_) { + return; + } if (typeof time !== 'number') { time = this.getMediaRefreshTime_(); } diff --git a/test/playlist-loader/playlist-loader.test.js b/test/playlist-loader/playlist-loader.test.js index cda50e594..c5176efbe 100644 --- a/test/playlist-loader/playlist-loader.test.js +++ b/test/playlist-loader/playlist-loader.test.js @@ -55,6 +55,39 @@ QUnit.module('New Playlist Loader', function(hooks) { }); 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}); @@ -63,6 +96,7 @@ QUnit.module('New Playlist Loader', function(hooks) { 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) { @@ -566,6 +600,18 @@ QUnit.module('New Playlist Loader', function(hooks) { }); + 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) { @@ -590,5 +636,16 @@ QUnit.module('New Playlist Loader', function(hooks) { 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}`); + } + }); }); From 11032175929ec6098d9b83169c257dba25453ce7 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Wed, 29 Sep 2021 16:45:27 -0400 Subject: [PATCH 07/27] dash media playlist loader tests --- src/playlist-loader/TODO.md | 6 +- .../dash-main-playlist-loader.js | 30 ++- .../dash-media-playlist-loader.js | 53 ++-- src/playlist-loader/utils.js | 2 +- src/util/deep-equal-object.js | 44 ++++ .../dash-media-playlist-loader.test.js | 240 ++++++++++++++++++ test/util/deep-equal-object.test.js | 41 +++ 7 files changed, 381 insertions(+), 35 deletions(-) create mode 100644 src/util/deep-equal-object.js create mode 100644 test/playlist-loader/dash-media-playlist-loader.test.js create mode 100644 test/util/deep-equal-object.test.js diff --git a/src/playlist-loader/TODO.md b/src/playlist-loader/TODO.md index 51472c90f..be15cf91c 100644 --- a/src/playlist-loader/TODO.md +++ b/src/playlist-loader/TODO.md @@ -1,5 +1,7 @@ * Finish DashMainPlaylistLoader * Finish interaction between main and media playlist loaders * migrate over sidx logic -* Finish DashMediaPlaylistLoader - * Finish interaction between main and media playlist loaders + * Write tests +* Finish HlsMediaPlaylistLoader + * write tests + * finish merge logic diff --git a/src/playlist-loader/dash-main-playlist-loader.js b/src/playlist-loader/dash-main-playlist-loader.js index 93dc1d857..c487db0f7 100644 --- a/src/playlist-loader/dash-main-playlist-loader.js +++ b/src/playlist-loader/dash-main-playlist-loader.js @@ -1,5 +1,6 @@ import PlaylistLoader from './playlist-loader.js'; -import {resolveUrl} from './resolve-url'; +import {resolveUrl} from '../resolve-url'; +// import {addPropertiesToMaster} from '../manifest.js'; import { parse as parseMpd, parseUTCTiming @@ -9,14 +10,14 @@ import { } from 'mpd-parser'; import {forEachMediaGroup} from './utils.js'; -export const findMedia = function(mainManifest, id) { +export const findMedia = function(mainManifest, uri) { if (!mainManifest || !mainManifest.playlists || !mainManifest.playlists.length) { return; } for (let i = 0; i < mainManifest.playlists.length; i++) { const media = mainManifest.playlists[i]; - if (media.id === id) { + if (media.uri === uri) { return media; } } @@ -31,7 +32,7 @@ export const findMedia = function(mainManifest, id) { for (let i = 0; i < properties.playlists; i++) { const media = mainManifest.playlists[i]; - if (media.id === id) { + if (media.uri === uri) { foundMedia = media; return true; } @@ -57,7 +58,7 @@ const mergeMainManifest = function(oldMain, newMain, sidxMapping) { // First update the media in playlist array for (let i = 0; i < newMain.playlists.length; i++) { const newMedia = newMain.playlists[i]; - const oldMedia = findMedia(oldMain, newMedia.id); + const oldMedia = findMedia(oldMain, newMedia.uri); const {updated, mergedMedia} = mergeMedia(oldMedia, newMedia); result.mergedManifest.playlists[i] = mergedMedia; @@ -94,14 +95,14 @@ class DashMainPlaylistLoader extends PlaylistLoader { constructor(uri, options) { super(uri, options); this.clientOffset_ = null; - this.sidxMapping_ = null; + this.sidxMapping_ = {}; this.mediaList_ = options.mediaList; this.clientClockOffset_ = null; this.setMediaRefreshTimeout_ = this.setMediaRefreshTimeout_.bind(this); } parseManifest_(manifestString, callback) { - this.syncClientServerClock_(manifestString, function(clientOffset) { + this.syncClientServerClock_(manifestString, (clientOffset) => { const parsedManifest = parseMpd(manifestString, { manifestUri: this.uri_, clientOffset, @@ -114,7 +115,20 @@ class DashMainPlaylistLoader extends PlaylistLoader { this.sidxMapping_ ); - callback(mergedManifest); + // TODO: why doesn't our mpd parser just do this... + // addPropertiesToMaster(mergedManifest); + mergedManifest.playlists.forEach(function(playlist) { + if (!playlist.id) { + playlist.id = playlist.attributes.NAME; + } + + if (!playlist.uri) { + playlist.uri = playlist.attributes.NAME; + } + }); + + // TODO: determine if we were updated or not. + callback(mergedManifest, true); }); } diff --git a/src/playlist-loader/dash-media-playlist-loader.js b/src/playlist-loader/dash-media-playlist-loader.js index 99445097b..ce1c6c0c8 100644 --- a/src/playlist-loader/dash-media-playlist-loader.js +++ b/src/playlist-loader/dash-media-playlist-loader.js @@ -1,59 +1,64 @@ import PlaylistLoader from './playlist-loader.js'; import {findMedia} from './dash-main-playlist-loader.js'; - -const findManifestString = function(manifestString, id) { - -}; - -const wasMediaUpdated = function(oldManifest, newManifest) { - -}; +import deepEqualObject from '../util/deep-equal-object.js'; class DashMediaPlaylistLoader extends PlaylistLoader { constructor(uri, options) { super(uri, options); + this.manifest_ = null; + this.manifestString_ = null; this.mainPlaylistLoader_ = options.mainPlaylistLoader; - this.onMainUpdated_ = this.onMainUpdated_.bind(this); + this.boundOnMainUpdated_ = () => this.onMainUpdated_(); - this.mainPlaylistLoader_.on('updated', this.onMainUpdated_); + this.mainPlaylistLoader_.on('updated', this.boundOnMainUpdated_); } + // noop, as media playlists in dash do not have + // a uri to refresh or a manifest string + refreshManifest_() {} + parseManifest_() {} + setMediaRefreshTimeout_() {} + clearMediaRefreshTimeout_() {} + getMediaRefreshTime_() {} + getManifestString_() {} + stopRequest() {} + onMainUpdated_() { - const oldManifestString = this.manifestString_; + if (!this.started_) { + return; + } const oldManifest = this.manifest_; - this.manifestString_ = findManifestString( - this.mainPlaylistLoader_.manifestString(), - this.uri() - ); - this.manifest_ = findMedia( this.mainPlaylistLoader_.manifest(), this.uri() ); - const wasUpdated = !oldManifestString || - this.manifestString_ !== oldManifestString || - wasMediaUpdated(oldManifest, this.manifest_); + const wasUpdated = !deepEqualObject(oldManifest, this.manifest_); if (wasUpdated) { - this.trigger('updated'); this.mainPlaylistLoader_.setMediaRefreshTime_(this.manifest().targetDuration * 1000); + this.trigger('updated'); } } - manifest() { - return findMedia(this.mainPlaylistLoader_.manifest(), this.uri()); - } - start() { if (!this.started_) { this.started_ = true; + this.onMainUpdated_(); + } + } + + stop() { + if (this.started_) { + this.started_ = false; + this.manifest_ = null; } } dispose() { + this.mainPlaylistLoader_.off('updated', this.boundOnMainUpdated_); super.dispose(); } } diff --git a/src/playlist-loader/utils.js b/src/playlist-loader/utils.js index 84bb0a548..177508704 100644 --- a/src/playlist-loader/utils.js +++ b/src/playlist-loader/utils.js @@ -1,5 +1,5 @@ import {mergeOptions} from 'video.js'; -import {resolveUrl} from './resolve-url'; +import {resolveUrl} from '../resolve-url'; export const isMediaUnchanged = (a, b) => a === b || (a.segments && b.segments && a.segments.length === b.segments.length && diff --git a/src/util/deep-equal-object.js b/src/util/deep-equal-object.js new file mode 100644 index 000000000..9ab7c9524 --- /dev/null +++ b/src/util/deep-equal-object.js @@ -0,0 +1,44 @@ +const deepEqualObject = function(a, b) { + if (!a || !b) { + return false; + } + + if (a === b) { + return true; + } + + const akeys = Object.keys(a).sort(); + const bkeys = Object.keys(b).sort(); + + // different number of keys + if (akeys.length !== bkeys.length) { + return false; + } + + for (let i = 0; i < akeys.length; i++) { + // different key in sorted list + if (akeys[i] !== bkeys[i]) { + return false; + } + const aVal = a[akeys[i]]; + const bVal = b[bkeys[i]]; + + // different value type + if (typeof aVal !== typeof bVal) { + return false; + } + + if (Array.isArray(aVal) || typeof aVal === 'object') { + if (!deepEqualObject(aVal, bVal)) { + return false; + } + continue; + } else if (aVal !== bVal) { + return false; + } + } + + return true; +}; + +export default deepEqualObject; 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..297454db7 --- /dev/null +++ b/test/playlist-loader/dash-media-playlist-loader.test.js @@ -0,0 +1,240 @@ +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} from '../test-helpers'; +import xhrFactory from '../../src/xhr'; +import testDataManifests from 'create-test-data!manifests'; + +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('dash-many-codecs.mpd', { + vhs: this.fakeVhs + }); + + this.setMediaRefreshTimeCalls = []; + + this.mainPlaylistLoader.setMediaRefreshTime_ = (time) => { + this.setMediaRefreshTimeCalls.push(time); + }; + + this.mainPlaylistLoader.start(); + + this.requests[0].respond(200, null, testDataManifests['dash-many-codecs']); + }); + + 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()'); + + QUnit.test('multiple calls do nothing', function(assert) { + this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.manifest().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()'); + + QUnit.test('multiple calls do nothing', function(assert) { + this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.manifest().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'); + + this.loader.manifest_ = {}; + this.loader.stop(); + + assert.deepEqual(this.loader.manifest_, {}, 'manifest not cleared'); + assert.false(this.loader.started_, 'still stopped'); + }); + + QUnit.module('#onMainUpdated_()'); + + QUnit.test('called via updated event on mainPlaylistLoader', function(assert) { + this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.manifest().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.manifest().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.manifest().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_, media, '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.manifest().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_, media, '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.manifest().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_, media, '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.manifest().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_, media, 'manifest set as expected'); + assert.true(updatedTriggered, 'updatedTriggered'); + assert.deepEqual( + this.setMediaRefreshTimeCalls, + [4000], + 'no set media refresh calls' + ); + }); + +}); + diff --git a/test/util/deep-equal-object.test.js b/test/util/deep-equal-object.test.js new file mode 100644 index 000000000..6a2340d18 --- /dev/null +++ b/test/util/deep-equal-object.test.js @@ -0,0 +1,41 @@ +import QUnit from 'qunit'; +import deepEqualObject from '../../src/util/deep-equal-object.js'; + +QUnit.module('Deep Equal Object'); + +QUnit.test('array', function(assert) { + assert.true(deepEqualObject(['a'], ['a']), 'same keys same order equal'); + assert.false(deepEqualObject(['a', 'b'], ['b', 'a']), 'different val order'); + assert.false(deepEqualObject(['a', 'b', 'c'], ['a', 'b']), 'extra key a'); + assert.false(deepEqualObject(['a', 'b'], ['a', 'b', 'c']), 'extra key b'); +}); + +QUnit.test('object', function(assert) { + assert.true(deepEqualObject({a: 'b'}, {a: 'b'}), 'two objects are equal'); + assert.false(deepEqualObject({a: 'b', f: 'a'}, {a: 'b'}), 'extra key a'); + assert.false(deepEqualObject({a: 'b'}, {a: 'b', f: 'a'}), 'extra key b'); +}); + +QUnit.test('complex', function(assert) { + assert.true(deepEqualObject( + {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(deepEqualObject( + {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'}} + ]}, + )); +}); From d89e9bd1d6ce9902eca1ac4e270402b1d0f59905 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Thu, 30 Sep 2021 10:50:42 -0400 Subject: [PATCH 08/27] parse media in main --- .../hls-main-playlist-loader.test.js | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/test/playlist-loader/hls-main-playlist-loader.test.js b/test/playlist-loader/hls-main-playlist-loader.test.js index 73272e909..b1a4ead78 100644 --- a/test/playlist-loader/hls-main-playlist-loader.test.js +++ b/test/playlist-loader/hls-main-playlist-loader.test.js @@ -49,7 +49,7 @@ QUnit.module('HLS Main Playlist Loader', function(hooks) { 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(this.loader.manifestString_, testDataManifests.master, 'manifest string set'); assert.true(updatedTriggered, 'updated was triggered'); }); @@ -85,7 +85,7 @@ QUnit.module('HLS Main Playlist Loader', function(hooks) { 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(this.loader.manifestString_, testDataManifests.master, 'manifest string set'); assert.equal(updatedTriggers, 1, 'one updated trigger'); this.loader.refreshManifest_(); @@ -115,7 +115,7 @@ QUnit.module('HLS Main Playlist Loader', function(hooks) { 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(this.loader.manifestString_, testDataManifests.master, 'manifest string set'); assert.equal(updatedTriggers, 1, 'one updated trigger'); this.loader.refreshManifest_(); @@ -124,8 +124,32 @@ QUnit.module('HLS Main Playlist Loader', function(hooks) { 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(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'); + }); }); From 66f66ca6a3dfdc1d6d28b1c52f4b7da4bb324b1f Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Wed, 13 Oct 2021 16:00:29 -0400 Subject: [PATCH 09/27] tests --- .../dash-main-playlist-loader.test.js | 40 +++++++++++++ .../hls-media-playlist-loader.test.js | 59 +++++++++++++++++++ test/playlist-loader/utils.test.js | 36 +++++++++++ 3 files changed, 135 insertions(+) create mode 100644 test/playlist-loader/dash-main-playlist-loader.test.js create mode 100644 test/playlist-loader/hls-media-playlist-loader.test.js create mode 100644 test/playlist-loader/utils.test.js 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..8264f901b --- /dev/null +++ b/test/playlist-loader/dash-main-playlist-loader.test.js @@ -0,0 +1,40 @@ +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; + }); + + QUnit.module('#start()'); + + QUnit.test('multiple calls do nothing', function(assert) { + this.loader = new DashMainPlaylistLoader('dash-many-codecs.mpd', { + vhs: this.fakeVhs + }); + }); + +}); + 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..1e0abb502 --- /dev/null +++ b/test/playlist-loader/hls-media-playlist-loader.test.js @@ -0,0 +1,59 @@ +import QUnit from 'qunit'; +import videojs from 'video.js'; +import HlsMediaPlaylistLoader 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.module('#parseManifest_()'); + +}); + diff --git a/test/playlist-loader/utils.test.js b/test/playlist-loader/utils.test.js new file mode 100644 index 000000000..32f8fa4a5 --- /dev/null +++ b/test/playlist-loader/utils.test.js @@ -0,0 +1,36 @@ +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('Playlist Loader Utils', 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('#todo()'); + + QUnit.test('todo', function(assert) { + + }); +}); + From 50e7422319e57dbbe10060a3a507258acd0a7b1c Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Wed, 27 Oct 2021 14:45:36 -0400 Subject: [PATCH 10/27] change to just deepEqual --- .../dash-media-playlist-loader.js | 4 +- src/util/deep-equal-object.js | 44 ----------------- src/util/deep-equal.js | 35 ++++++++++++++ test/util/deep-equal-object.test.js | 41 ---------------- test/util/deep-equal.test.js | 47 +++++++++++++++++++ 5 files changed, 84 insertions(+), 87 deletions(-) delete mode 100644 src/util/deep-equal-object.js create mode 100644 src/util/deep-equal.js delete mode 100644 test/util/deep-equal-object.test.js create mode 100644 test/util/deep-equal.test.js diff --git a/src/playlist-loader/dash-media-playlist-loader.js b/src/playlist-loader/dash-media-playlist-loader.js index ce1c6c0c8..bdee2a389 100644 --- a/src/playlist-loader/dash-media-playlist-loader.js +++ b/src/playlist-loader/dash-media-playlist-loader.js @@ -1,6 +1,6 @@ import PlaylistLoader from './playlist-loader.js'; import {findMedia} from './dash-main-playlist-loader.js'; -import deepEqualObject from '../util/deep-equal-object.js'; +import deepEqual from '../util/deep-equal.js'; class DashMediaPlaylistLoader extends PlaylistLoader { constructor(uri, options) { @@ -35,7 +35,7 @@ class DashMediaPlaylistLoader extends PlaylistLoader { this.uri() ); - const wasUpdated = !deepEqualObject(oldManifest, this.manifest_); + const wasUpdated = !deepEqual(oldManifest, this.manifest_); if (wasUpdated) { this.mainPlaylistLoader_.setMediaRefreshTime_(this.manifest().targetDuration * 1000); diff --git a/src/util/deep-equal-object.js b/src/util/deep-equal-object.js deleted file mode 100644 index 9ab7c9524..000000000 --- a/src/util/deep-equal-object.js +++ /dev/null @@ -1,44 +0,0 @@ -const deepEqualObject = function(a, b) { - if (!a || !b) { - return false; - } - - if (a === b) { - return true; - } - - const akeys = Object.keys(a).sort(); - const bkeys = Object.keys(b).sort(); - - // different number of keys - if (akeys.length !== bkeys.length) { - return false; - } - - for (let i = 0; i < akeys.length; i++) { - // different key in sorted list - if (akeys[i] !== bkeys[i]) { - return false; - } - const aVal = a[akeys[i]]; - const bVal = b[bkeys[i]]; - - // different value type - if (typeof aVal !== typeof bVal) { - return false; - } - - if (Array.isArray(aVal) || typeof aVal === 'object') { - if (!deepEqualObject(aVal, bVal)) { - return false; - } - continue; - } else if (aVal !== bVal) { - return false; - } - } - - return true; -}; - -export default deepEqualObject; diff --git a/src/util/deep-equal.js b/src/util/deep-equal.js new file mode 100644 index 000000000..eee5b96ce --- /dev/null +++ b/src/util/deep-equal.js @@ -0,0 +1,35 @@ +const isObject = (obj) => + !!obj && typeof obj === 'object'; + +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/util/deep-equal-object.test.js b/test/util/deep-equal-object.test.js deleted file mode 100644 index 6a2340d18..000000000 --- a/test/util/deep-equal-object.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import QUnit from 'qunit'; -import deepEqualObject from '../../src/util/deep-equal-object.js'; - -QUnit.module('Deep Equal Object'); - -QUnit.test('array', function(assert) { - assert.true(deepEqualObject(['a'], ['a']), 'same keys same order equal'); - assert.false(deepEqualObject(['a', 'b'], ['b', 'a']), 'different val order'); - assert.false(deepEqualObject(['a', 'b', 'c'], ['a', 'b']), 'extra key a'); - assert.false(deepEqualObject(['a', 'b'], ['a', 'b', 'c']), 'extra key b'); -}); - -QUnit.test('object', function(assert) { - assert.true(deepEqualObject({a: 'b'}, {a: 'b'}), 'two objects are equal'); - assert.false(deepEqualObject({a: 'b', f: 'a'}, {a: 'b'}), 'extra key a'); - assert.false(deepEqualObject({a: 'b'}, {a: 'b', f: 'a'}), 'extra key b'); -}); - -QUnit.test('complex', function(assert) { - assert.true(deepEqualObject( - {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(deepEqualObject( - {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'}} - ]}, - )); -}); 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'}} + ]}, + )); +}); From dc5a7071363e911e0ba481325625790f0c4687a4 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Wed, 27 Oct 2021 15:47:48 -0400 Subject: [PATCH 11/27] finish hls media playlist loader --- src/playlist-loader/TODO.md | 3 - .../hls-main-playlist-loader.js | 1 - .../hls-media-playlist-loader.js | 154 +++++++--- src/playlist-loader/utils.js | 13 +- .../hls-media-playlist-loader.test.js | 282 +++++++++++++++++- 5 files changed, 397 insertions(+), 56 deletions(-) diff --git a/src/playlist-loader/TODO.md b/src/playlist-loader/TODO.md index be15cf91c..d1ca93f5e 100644 --- a/src/playlist-loader/TODO.md +++ b/src/playlist-loader/TODO.md @@ -2,6 +2,3 @@ * Finish interaction between main and media playlist loaders * migrate over sidx logic * Write tests -* Finish HlsMediaPlaylistLoader - * write tests - * finish merge logic diff --git a/src/playlist-loader/hls-main-playlist-loader.js b/src/playlist-loader/hls-main-playlist-loader.js index 96b4f056f..38c17e76e 100644 --- a/src/playlist-loader/hls-main-playlist-loader.js +++ b/src/playlist-loader/hls-main-playlist-loader.js @@ -18,7 +18,6 @@ class HlsMainPlaylistLoader extends PlaylistLoader { start() { // never re-request the manifest. if (this.manifest_) { - // TODO: we may have to trigger updated here this.started_ = true; return; } diff --git a/src/playlist-loader/hls-media-playlist-loader.js b/src/playlist-loader/hls-media-playlist-loader.js index 0e542eee9..3d2d3a4f2 100644 --- a/src/playlist-loader/hls-media-playlist-loader.js +++ b/src/playlist-loader/hls-media-playlist-loader.js @@ -1,7 +1,7 @@ import PlaylistLoader from './playlist-loader.js'; import {parseManifest} from '../manifest.js'; -import {mergeOptions} from 'video.js'; import {mergeSegments} from './utils.js'; +import deepEqual from '../util/deep-equal.js'; /** * Calculates the time to wait before refreshing a live playlist @@ -13,8 +13,8 @@ import {mergeSegments} from './utils.js'; * @return {number} * The time in ms to wait before refreshing the live playlist */ -const timeBeforeRefresh = function(manifest, update) { - const lastSegment = manifest.segments[manifest.segments.length - 1]; +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; @@ -27,90 +27,150 @@ const timeBeforeRefresh = function(manifest, update) { 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. +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; +}; + export const getAllSegments = function(manifest) { const segments = manifest.segments || []; - const preloadSegment = manifest.preloadSegment; + 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') { - return segments; + add = false; + break; } } } - // set the duration for our preload segment to target duration. - preloadSegment.duration = manifest.targetDuration; - preloadSegment.preload = true; - - segments.push(preloadSegment); - } - return segments; -}; + if (add) { + preloadSegment = clonePreloadSegment(preloadSegment); -const mergeMedia = function(oldMedia, newMedia) { - const result = { - mergedMedia: newMedia, - updated: true - }; - - if (!oldMedia) { - return result; - } + // set the duration for our preload segment to target duration. + preloadSegment.duration = manifest.targetDuration; + preloadSegment.preload = true; - result.mergedManifest = mergeOptions(oldMedia, newMedia); - - // always use the new manifest's preload segment - if (result.mergedManifest.preloadSegment && !newMedia.preloadSegment) { - delete result.mergedManifest.preloadSegment; + segments.push(preloadSegment); + } } - newMedia.segments = getAllSegments(newMedia); - - if (newMedia.skip) { - newMedia.segments = newMedia.segments || []; + 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 < newMedia.skip.skippedSegments; i++) { - newMedia.segments.unshift({skipped: true}); + for (let i = 0; i < manifest.skip.skippedSegments; i++) { + manifest.segments.unshift({skipped: true}); } } - // if the update could overlap existing segment information, merge the two segment lists - const {updated, mergedSegments} = mergeSegments(oldMedia, newMedia); - - if (updated) { - result.updated = true; - } + return segments; +}; - result.mergedManifest.segments = mergedSegments; +export const mergeMedia = function({oldMedia, newMedia, baseUri}) { + oldMedia = oldMedia || {}; + newMedia = newMedia || {}; + // 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.segments, + newSegments: newMedia.segments, + baseUri, + offset: newMedia.mediaSequence - oldMedia.mediaSequence + }); + + let mediaUpdated = !oldMedia || segmentResult.updated; + const mergedMedia = {segments: segmentResult.segments}; + + const keys = []; + + Object.keys(oldMedia).concat(Object.keys(newMedia)).forEach(function(key) { + // segments are merged elsewhere + if (key === 'segments' || keys.indexOf(key) !== -1) { + return; + } + keys.push(key); + }); + + keys.forEach(function(key) { + // both have the key + if (oldMedia.hasOwnProperty(key) && newMedia.hasOwnProperty(key)) { + // if the value is different media was updated + if (!deepEqual(oldMedia[key], newMedia[key])) { + mediaUpdated = true; + } + // regardless grab the value from new media + mergedMedia[key] = newMedia[key]; + // only oldMedia has the key don't bring it over, but media was updated + } else if (oldMedia.hasOwnProperty(key) && !newMedia.hasOwnProperty(key)) { + mediaUpdated = true; + // otherwise the key came from newMedia + } else { + mediaUpdated = true; + mergedMedia[key] = newMedia[key]; + } + }); - return result; + return {updated: mediaUpdated, media: mergedMedia}; }; 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}`), + 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 updated = true; - this.mediaRefreshTime_ = timeBeforeRefresh(this.manifest(), updated); + // TODO: this should go in parseManifest, as it + // always needs to happen directly afterwards + parsedMedia.segments = getAllSegments(parsedMedia); - callback(mergeMedia(this.manifest_, parsedMedia), updated); + const {media, updated} = mergeMedia({ + oldMedia: this.manifest_, + newMedia: parsedMedia, + baseUri: this.uri() + }); + + this.mediaRefreshTime_ = timeBeforeRefresh(media, updated); + + callback(media, updated); } start() { @@ -118,10 +178,12 @@ class HlsMediaPlaylistLoader extends PlaylistLoader { // 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/utils.js b/src/playlist-loader/utils.js index 177508704..ccb8a81c7 100644 --- a/src/playlist-loader/utils.js +++ b/src/playlist-loader/utils.js @@ -60,6 +60,7 @@ const mergeSegment = function(a, b, baseUri) { }; if (!a) { + result.updated = true; return b; } @@ -102,13 +103,15 @@ const mergeSegment = function(a, b, baseUri) { }; export const mergeSegments = function({oldSegments, newSegments, offset = 0, baseUri}) { + oldSegments = oldSegments || []; + newSegments = newSegments || []; const result = { - mergedSegments: newSegments, + segments: [], updated: false }; - if (!oldSegments || !oldSegments.length) { - return result; + if (!oldSegments || !oldSegments.length || oldSegments.length !== newSegments.length) { + result.updated = true; } let currentMap; @@ -121,7 +124,7 @@ export const mergeSegments = function({oldSegments, newSegments, offset = 0, bas if (oldSegment) { currentMap = oldSegment.map || currentMap; - mergedSegment = mergeSegment(oldSegment, newSegment); + mergedSegment = mergeSegment(oldSegment, newSegment, baseUri); } else { // carry over map to new segment if it is missing if (currentMap && !newSegment.map) { @@ -131,7 +134,7 @@ export const mergeSegments = function({oldSegments, newSegments, offset = 0, bas mergedSegment = newSegment; } - result.mergedSegments.push(resolveSegmentUris(mergedSegment, baseUri)); + result.segments.push(resolveSegmentUris(mergedSegment, baseUri)); } return result; }; diff --git a/test/playlist-loader/hls-media-playlist-loader.test.js b/test/playlist-loader/hls-media-playlist-loader.test.js index 1e0abb502..6bbad59c1 100644 --- a/test/playlist-loader/hls-media-playlist-loader.test.js +++ b/test/playlist-loader/hls-media-playlist-loader.test.js @@ -1,6 +1,11 @@ import QUnit from 'qunit'; import videojs from 'video.js'; -import HlsMediaPlaylistLoader from '../../src/playlist-loader/hls-media-playlist-loader.js'; +import { + default as HlsMediaPlaylistLoader, + getAllSegments, + mergeMedia, + 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'; @@ -53,7 +58,282 @@ QUnit.module('HLS Media Playlist Loader', function(hooks) { 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', + ); + }); + + 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' + ); + }); + }); From ec33235953ea9781168582c645d35f04e6faa0df Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Thu, 28 Oct 2021 15:31:22 -0400 Subject: [PATCH 12/27] finish utils tests --- src/playlist-loader/TODO.md | 2 +- src/playlist-loader/utils.js | 101 +++--- test/playlist-loader/utils.test.js | 533 +++++++++++++++++++++++++++-- 3 files changed, 569 insertions(+), 67 deletions(-) diff --git a/src/playlist-loader/TODO.md b/src/playlist-loader/TODO.md index d1ca93f5e..62604e72a 100644 --- a/src/playlist-loader/TODO.md +++ b/src/playlist-loader/TODO.md @@ -1,4 +1,4 @@ * Finish DashMainPlaylistLoader - * Finish interaction between main and media playlist loaders * migrate over sidx logic + * finish merging logic * Write tests diff --git a/src/playlist-loader/utils.js b/src/playlist-loader/utils.js index ccb8a81c7..2b51687f8 100644 --- a/src/playlist-loader/utils.js +++ b/src/playlist-loader/utils.js @@ -1,12 +1,6 @@ import {mergeOptions} from 'video.js'; import {resolveUrl} from '../resolve-url'; -export const isMediaUnchanged = (a, b) => a === b || - (a.segments && b.segments && a.segments.length === b.segments.length && - a.endList === b.endList && - a.mediaSequence === b.mediaSequence && - (a.preloadSegment && b.preloadSegment && a.preloadSegment === b.preloadSegment)); - 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 @@ -53,36 +47,41 @@ const resolveSegmentUris = function(segment, baseUri) { * * @return {Object} the merged segment */ -const mergeSegment = function(a, b, baseUri) { - const result = { - mergedSegment: b, - updated: false - }; +export const mergeSegment = function(a, b) { + let segment = b; + let updated = false; if (!a) { - result.updated = true; - return b; + updated = true; } - result.mergedSegment = mergeOptions(a, b); + 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) { - delete result.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) { - delete result.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]) { - result.parts[i] = mergeOptions(a.parts[i], b.parts[i]); + segment.parts[i] = mergeOptions(a.parts[i], b.parts[i]); } } } @@ -90,16 +89,17 @@ const mergeSegment = function(a, b, baseUri) { // set skipped to false for segments that have // have had information merged from the old segment. if (!a.skipped && b.skipped) { - result.skipped = false; + delete segment.skipped; } // set preload to false for segments that have // had information added in the new segment. if (a.preload && !b.preload) { - result.preload = false; + updated = true; + delete segment.preload; } - return result; + return {updated, segment}; }; export const mergeSegments = function({oldSegments, newSegments, offset = 0, baseUri}) { @@ -119,19 +119,21 @@ export const mergeSegments = function({oldSegments, newSegments, offset = 0, bas for (let newIndex = 0; newIndex < newSegments.length; newIndex++) { const oldSegment = oldSegments[newIndex + offset]; const newSegment = newSegments[newIndex]; - let mergedSegment; - if (oldSegment) { - currentMap = oldSegment.map || currentMap; + const {updated, segment} = mergeSegment(oldSegment, newSegment); - mergedSegment = mergeSegment(oldSegment, newSegment, baseUri); - } else { - // carry over map to new segment if it is missing - if (currentMap && !newSegment.map) { - newSegment.map = currentMap; - } + if (updated) { + result.updated = updated; + } + + const mergedSegment = segment; - mergedSegment = newSegment; + // 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)); @@ -139,26 +141,39 @@ export const mergeSegments = function({oldSegments, newSegments, offset = 0, bas return result; }; +const MEDIA_GROUP_TYPES = ['AUDIO', 'SUBTITLES']; + /** - * Loops through all supported media groups in master and calls the provided + * 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} master - * The parsed master manifest object + * @param {Object} mainManifest + * The parsed main manifest object * @param {Function} callback - * Callback to call for each media group + * Callback to call for each media group, + * *NOTE* The return value is used here. Any true + * value will stop the loop. */ -export const forEachMediaGroup = (master, callback) => { - if (!master.mediaGroups) { +export const forEachMediaGroup = (mainManifest, callback) => { + if (!mainManifest.mediaGroups) { return; } - ['AUDIO', 'SUBTITLES'].forEach((mediaType) => { - if (!master.mediaGroups[mediaType]) { - 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 master.mediaGroups[mediaType]) { - for (const labelKey in master.mediaGroups[mediaType][groupKey]) { - const mediaProperties = master.mediaGroups[mediaType][groupKey][labelKey]; + 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); @@ -167,5 +182,5 @@ export const forEachMediaGroup = (master, callback) => { } } } - }); + } }; diff --git a/test/playlist-loader/utils.test.js b/test/playlist-loader/utils.test.js index 32f8fa4a5..7ddeef3d6 100644 --- a/test/playlist-loader/utils.test.js +++ b/test/playlist-loader/utils.test.js @@ -1,36 +1,523 @@ 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'; +import { + forEachMediaGroup, + mergeSegments, + mergeSegment +} from '../../src/playlist-loader/utils.js'; +import {absoluteUrl} from '../test-helpers.js'; QUnit.module('Playlist Loader Utils', function(hooks) { - hooks.beforeEach(function(assert) { - this.env = useFakeEnvironment(assert); - this.clock = this.env.clock; - this.requests = this.env.requests; - this.fakeVhs = { - xhr: xhrFactory() + + QUnit.module('forEachMediaGroup'); + + 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'} + } + } + } }; - this.logLines = []; - this.oldDebugLog = videojs.log.debug; - videojs.log.debug = (...args) => { - this.logLines.push(args.join(' ')); + + 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' + ); }); - hooks.afterEach(function(assert) { - if (this.loader) { - this.loader.dispose(); - } - this.env.restore(); - videojs.log.debug = this.oldDebugLog; + + 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.module('#todo()'); + 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: 'part.uri'}], + preloadHints: [{uri: 'hint.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: 'part.uri', resolvedUri: absoluteUrl('part.uri')}], + preloadHints: [{uri: 'hint.uri', resolvedUri: absoluteUrl('hint.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('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('todo', function(assert) { + 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'); }); }); From dac25932dcf4114e080065149239ebf89e8b67b9 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Fri, 29 Oct 2021 12:03:05 -0400 Subject: [PATCH 13/27] increase code coverage --- test/playlist-loader/utils.test.js | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/test/playlist-loader/utils.test.js b/test/playlist-loader/utils.test.js index 7ddeef3d6..e9c9dd072 100644 --- a/test/playlist-loader/utils.test.js +++ b/test/playlist-loader/utils.test.js @@ -394,8 +394,8 @@ QUnit.module('Playlist Loader Utils', function(hooks) { key: {uri: 'mapkey.uri'} }, key: {uri: 'key.uri'}, - parts: [{uri: 'part.uri'}], - preloadHints: [{uri: 'hint.uri'}] + parts: [{uri: 'part1.uri'}, {resolvedUri: absoluteUrl('part2.uri'), uri: 'part2.uri'}], + preloadHints: [{uri: 'hint1.uri'}, {resolvedUri: absoluteUrl('hint2.uri'), uri: 'hint2.uri'}] }] }); @@ -409,8 +409,14 @@ QUnit.module('Playlist Loader Utils', function(hooks) { key: {uri: 'mapkey.uri', resolvedUri: absoluteUrl('mapkey.uri')} }, key: {uri: 'key.uri', resolvedUri: absoluteUrl('key.uri')}, - parts: [{uri: 'part.uri', resolvedUri: absoluteUrl('part.uri')}], - preloadHints: [{uri: 'hint.uri', resolvedUri: absoluteUrl('hint.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'); }); @@ -493,6 +499,21 @@ QUnit.module('Playlist Loader Utils', function(hooks) { 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'}; From 59d2360d69e87aa112e9caabde57381311f9eec9 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Fri, 29 Oct 2021 12:08:20 -0400 Subject: [PATCH 14/27] move to helper --- src/playlist-loader/hls-media-playlist-loader.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/playlist-loader/hls-media-playlist-loader.js b/src/playlist-loader/hls-media-playlist-loader.js index 3d2d3a4f2..bfbcc9da9 100644 --- a/src/playlist-loader/hls-media-playlist-loader.js +++ b/src/playlist-loader/hls-media-playlist-loader.js @@ -98,6 +98,16 @@ export const getAllSegments = function(manifest) { return segments; }; +const parseManifest_ = function(options) { + const parsedMedia = parseManifest(options); + + // TODO: this should go in parseManifest, as it + // always needs to happen directly afterwards + parsedMedia.segments = getAllSegments(parsedMedia); + + return parsedMedia; +}; + export const mergeMedia = function({oldMedia, newMedia, baseUri}) { oldMedia = oldMedia || {}; newMedia = newMedia || {}; @@ -149,7 +159,7 @@ export const mergeMedia = function({oldMedia, newMedia, baseUri}) { class HlsMediaPlaylistLoader extends PlaylistLoader { parseManifest_(manifestString, callback) { - const parsedMedia = parseManifest({ + 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, @@ -158,10 +168,6 @@ class HlsMediaPlaylistLoader extends PlaylistLoader { experimentalLLHLS: this.options_.experimentalLLHLS }); - // TODO: this should go in parseManifest, as it - // always needs to happen directly afterwards - parsedMedia.segments = getAllSegments(parsedMedia); - const {media, updated} = mergeMedia({ oldMedia: this.manifest_, newMedia: parsedMedia, From 86c785bd367fa44d80bbc33aa2d518dbbb33fbc6 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Fri, 29 Oct 2021 14:00:14 -0400 Subject: [PATCH 15/27] refactor for sidx and merge logic --- src/playlist-loader/TODO.md | 7 +- .../dash-main-playlist-loader.js | 117 +------------ .../dash-media-playlist-loader.js | 160 ++++++++++++++++-- .../hls-media-playlist-loader.js | 51 +----- src/playlist-loader/playlist-loader.js | 11 ++ src/playlist-loader/utils.js | 63 +++++++ 6 files changed, 237 insertions(+), 172 deletions(-) diff --git a/src/playlist-loader/TODO.md b/src/playlist-loader/TODO.md index 62604e72a..fb90550a0 100644 --- a/src/playlist-loader/TODO.md +++ b/src/playlist-loader/TODO.md @@ -1,4 +1,5 @@ -* Finish DashMainPlaylistLoader - * migrate over sidx logic - * finish merging logic + + +* Finish DashMain/Media PlaylistLoader * Write tests + * verify merging diff --git a/src/playlist-loader/dash-main-playlist-loader.js b/src/playlist-loader/dash-main-playlist-loader.js index c487db0f7..4f55aca35 100644 --- a/src/playlist-loader/dash-main-playlist-loader.js +++ b/src/playlist-loader/dash-main-playlist-loader.js @@ -1,95 +1,7 @@ import PlaylistLoader from './playlist-loader.js'; import {resolveUrl} from '../resolve-url'; -// import {addPropertiesToMaster} from '../manifest.js'; -import { - parse as parseMpd, - parseUTCTiming -// TODO -// addSidxSegmentsToPlaylist, -// generateSidxKey, -} from 'mpd-parser'; -import {forEachMediaGroup} from './utils.js'; - -export const findMedia = function(mainManifest, uri) { - if (!mainManifest || !mainManifest.playlists || !mainManifest.playlists.length) { - return; - } - for (let i = 0; i < mainManifest.playlists.length; i++) { - const media = mainManifest.playlists[i]; - - if (media.uri === uri) { - return media; - } - } - - let foundMedia; - - forEachMediaGroup(mainManifest, function(properties, type, group, label) { - if (!properties.playlists) { - return; - } - - for (let i = 0; i < properties.playlists; i++) { - const media = mainManifest.playlists[i]; - - if (media.uri === uri) { - foundMedia = media; - return true; - } - } - }); - - return foundMedia; -}; - -const mergeMedia = function(oldMedia, newMedia) { - -}; - -const mergeMainManifest = function(oldMain, newMain, sidxMapping) { - const result = newMain; - - if (!oldMain) { - return result; - } - - result.playlists = []; - - // First update the media in playlist array - for (let i = 0; i < newMain.playlists.length; i++) { - const newMedia = newMain.playlists[i]; - const oldMedia = findMedia(oldMain, newMedia.uri); - const {updated, mergedMedia} = mergeMedia(oldMedia, newMedia); - - result.mergedManifest.playlists[i] = mergedMedia; - - if (updated) { - result.updated = true; - } - } - - // Then update media group playlists - forEachMediaGroup(newMain, (newProperties, type, group, label) => { - const oldProperties = oldMain.mediaGroups && - oldMain.mediaGroups[type] && oldMain.mediaGroups[type][group] && - oldMain.mergedMedia[type][group][label]; - - // nothing to merge. - if (!oldProperties || !newProperties || !oldProperties.playlists || !newProperties.playlists || !oldProperties.Playlists.length || !newProperties.playlists.length) { - return; - } - - for (let i = 0; i < newProperties.playlists.length; i++) { - const newMedia = newProperties.playlists[i]; - const oldMedia = oldProperties.playlists[i]; - const mergedMedia = mergeMedia(oldMedia, newMedia); - - result.mediaGroups[type][group][label].playlists[i] = mergedMedia; - } - }); - - return result; -}; +import {parse as parseMpd, parseUTCTiming} from 'mpd-parser'; +import {mergeManifest} from './utils.js'; class DashMainPlaylistLoader extends PlaylistLoader { constructor(uri, options) { @@ -109,26 +21,11 @@ class DashMainPlaylistLoader extends PlaylistLoader { sidxMapping: this.sidxMapping_ }); - const mergedManifest = mergeMainManifest( - this.manifest_, - parsedManifest, - this.sidxMapping_ - ); - - // TODO: why doesn't our mpd parser just do this... - // addPropertiesToMaster(mergedManifest); - mergedManifest.playlists.forEach(function(playlist) { - if (!playlist.id) { - playlist.id = playlist.attributes.NAME; - } - - if (!playlist.uri) { - playlist.uri = playlist.attributes.NAME; - } - }); + // merge everything except for playlists, they will merge themselves + const main = mergeManifest(this.manifest_, parsedManifest, ['playlists']); - // TODO: determine if we were updated or not. - callback(mergedManifest, true); + // always trigger updated, as playlists will have to update themselves + callback(main, true); }); } @@ -167,6 +64,8 @@ class DashMainPlaylistLoader extends PlaylistLoader { }); } + // used by dash media playlist loaders in cases where + // minimumUpdatePeriod is zero setMediaRefreshTime_(time) { if (!this.getMediaRefreshTime_()) { this.setMediaRefreshTimeout_(time); diff --git a/src/playlist-loader/dash-media-playlist-loader.js b/src/playlist-loader/dash-media-playlist-loader.js index bdee2a389..a585fb7f8 100644 --- a/src/playlist-loader/dash-media-playlist-loader.js +++ b/src/playlist-loader/dash-media-playlist-loader.js @@ -1,6 +1,52 @@ import PlaylistLoader from './playlist-loader.js'; -import {findMedia} from './dash-main-playlist-loader.js'; -import deepEqual from '../util/deep-equal.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, forEachMediaGroup} from './utils.js'; + +export const getMediaAccessor = function(mainManifest, uri) { + if (!mainManifest || !mainManifest.playlists || !mainManifest.playlists.length) { + return; + } + for (let i = 0; i < mainManifest.playlists.length; i++) { + const media = mainManifest.playlists[i]; + + if (media.uri === uri) { + return { + get: () => mainManifest.playlists[i], + set: (v) => { + mainManifest.playlists[i] = v; + } + }; + } + } + + let result; + + forEachMediaGroup(mainManifest, function(properties, type, group, label) { + if (!properties.playlists) { + return; + } + + for (let i = 0; i < properties.playlists; i++) { + const media = properties.playlists[i]; + + if (media.uri === uri) { + result = { + get: () => properties.playlists[i], + set: (v) => { + properties.playlists[i] = v; + } + }; + return true; + } + } + }); + + return result; +}; class DashMediaPlaylistLoader extends PlaylistLoader { constructor(uri, options) { @@ -8,6 +54,7 @@ class DashMediaPlaylistLoader extends PlaylistLoader { this.manifest_ = null; this.manifestString_ = null; + this.sidx_ = null; this.mainPlaylistLoader_ = options.mainPlaylistLoader; this.boundOnMainUpdated_ = () => this.onMainUpdated_(); @@ -22,25 +69,118 @@ class DashMediaPlaylistLoader extends PlaylistLoader { clearMediaRefreshTimeout_() {} getMediaRefreshTime_() {} getManifestString_() {} - stopRequest() {} onMainUpdated_() { if (!this.started_) { return; } - const oldManifest = this.manifest_; - - this.manifest_ = findMedia( + const oldMedia = this.manifest_; + const mediaAccessor = getMediaAccessor( this.mainPlaylistLoader_.manifest(), this.uri() ); - const wasUpdated = !deepEqual(oldManifest, this.manifest_); + // redefine the getters and setters. + Object.defineProperty(this, 'manifest_', { + get: mediaAccessor.get, + set: mediaAccessor.set, + writeable: true, + configurable: true, + enumerable: true + }); + + // use them + const newMedia = this.manifest_; + + 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; - if (wasUpdated) { - this.mainPlaylistLoader_.setMediaRefreshTime_(this.manifest().targetDuration * 1000); - this.trigger('updated'); + const {media, updated} = mergeMedia({ + oldMedia, + newMedia, + uri: this.mainPlaylistLoader_.uri() + }); + + this.manifest_ = media; + + if (updated) { + this.mainPlaylistLoader_.setMediaRefreshTime_(this.manifest().targetDuration * 1000); + this.trigger('updated'); + } + }); + } + + requestSidx_(callback) { + if ((this.sidx_ && this.manifest_.sidx) || !this.manifest_.sidx) { + return callback(); } + const uri = this.manifest_.sidx.resolvedUri; + + const parseSidx_ = (error, request) => { + if (error) { + this.error_ = typeof err === 'object' && !(error instanceof Error) ? error : { + status: request.status, + message: 'DASH sidx request error at URL: ' + request.uri, + response: request.response, + // MEDIA_ERR_NETWORK + code: 2 + }; + + this.trigger('error'); + return; + } + + let sidx; + + try { + sidx = parseSidx(toUint8(request.response).subarray(8)); + } catch (e) { + // sidx parsing failed. + this.error_ = e; + this.trigger('error'); + return; + } + + this.sidx = sidx; + callback(); + + }; + + this.request_ = containerRequest(uri, this.vhs_.xhr, (error, request, container, bytes) => { + this.request_ = null; + + if (error || !container || container !== 'mp4') { + return parseSidx_(error || { + status: request.status, + message: `Unsupported ${container || 'unknown'} container type for sidx segment at URL: ${uri}`, + blacklistDuration: Infinity, + // MEDIA_ERR_NETWORK + code: 2 + }, null); + } + + // 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_(error, { + 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_, false); + }); } start() { diff --git a/src/playlist-loader/hls-media-playlist-loader.js b/src/playlist-loader/hls-media-playlist-loader.js index bfbcc9da9..4ca71c2a3 100644 --- a/src/playlist-loader/hls-media-playlist-loader.js +++ b/src/playlist-loader/hls-media-playlist-loader.js @@ -1,7 +1,6 @@ import PlaylistLoader from './playlist-loader.js'; import {parseManifest} from '../manifest.js'; -import {mergeSegments} from './utils.js'; -import deepEqual from '../util/deep-equal.js'; +import {mergeMedia} from './utils.js'; /** * Calculates the time to wait before refreshing a live playlist @@ -108,54 +107,6 @@ const parseManifest_ = function(options) { return parsedMedia; }; -export const mergeMedia = function({oldMedia, newMedia, baseUri}) { - oldMedia = oldMedia || {}; - newMedia = newMedia || {}; - // 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.segments, - newSegments: newMedia.segments, - baseUri, - offset: newMedia.mediaSequence - oldMedia.mediaSequence - }); - - let mediaUpdated = !oldMedia || segmentResult.updated; - const mergedMedia = {segments: segmentResult.segments}; - - const keys = []; - - Object.keys(oldMedia).concat(Object.keys(newMedia)).forEach(function(key) { - // segments are merged elsewhere - if (key === 'segments' || keys.indexOf(key) !== -1) { - return; - } - keys.push(key); - }); - - keys.forEach(function(key) { - // both have the key - if (oldMedia.hasOwnProperty(key) && newMedia.hasOwnProperty(key)) { - // if the value is different media was updated - if (!deepEqual(oldMedia[key], newMedia[key])) { - mediaUpdated = true; - } - // regardless grab the value from new media - mergedMedia[key] = newMedia[key]; - // only oldMedia has the key don't bring it over, but media was updated - } else if (oldMedia.hasOwnProperty(key) && !newMedia.hasOwnProperty(key)) { - mediaUpdated = true; - // otherwise the key came from newMedia - } else { - mediaUpdated = true; - mergedMedia[key] = newMedia[key]; - } - }); - - return {updated: mediaUpdated, media: mergedMedia}; -}; - class HlsMediaPlaylistLoader extends PlaylistLoader { parseManifest_(manifestString, callback) { diff --git a/src/playlist-loader/playlist-loader.js b/src/playlist-loader/playlist-loader.js index 8bb5b35bc..a4a30469d 100644 --- a/src/playlist-loader/playlist-loader.js +++ b/src/playlist-loader/playlist-loader.js @@ -106,6 +106,17 @@ class PlaylistLoader extends videojs.EventTarget { }); } + requestError_(error, request) { + this.error_ = typeof error === 'object' && !(error instanceof Error) ? error : { + status: request.status, + message: `Playlist request error at URI ${request.uri}`, + response: request.response, + code: (request.status >= 500) ? 4 : 2 + }; + + this.trigger('error'); + } + start() { if (!this.started_) { this.started_ = true; diff --git a/src/playlist-loader/utils.js b/src/playlist-loader/utils.js index 2b51687f8..a93d7f64a 100644 --- a/src/playlist-loader/utils.js +++ b/src/playlist-loader/utils.js @@ -1,5 +1,6 @@ import {mergeOptions} from 'video.js'; import {resolveUrl} from '../resolve-url'; +import deepEqual from '../util/deep-equal.js'; const resolveSegmentUris = function(segment, baseUri) { // preloadSegment will not have a uri at all @@ -184,3 +185,65 @@ export const forEachMediaGroup = (mainManifest, callback) => { } } }; + +export const mergeManifest = function(a, b, excludeKeys) { + excludeKeys = excludeKeys || []; + + let updated = !a; + const mergedManifest = {}; + + 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}; +}; + +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 + }; +}; From 297510d0a5e3808d6415dd988711e9e054bbc45a Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Fri, 29 Oct 2021 14:00:14 -0400 Subject: [PATCH 16/27] refactor for sidx and merge logic --- src/playlist-loader/dash-main-playlist-loader.js | 2 ++ src/playlist-loader/dash-media-playlist-loader.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/playlist-loader/dash-main-playlist-loader.js b/src/playlist-loader/dash-main-playlist-loader.js index 4f55aca35..281017651 100644 --- a/src/playlist-loader/dash-main-playlist-loader.js +++ b/src/playlist-loader/dash-main-playlist-loader.js @@ -88,6 +88,8 @@ class DashMainPlaylistLoader extends PlaylistLoader { // 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 media there target duration. if (minimumUpdatePeriod === 0) { return; } diff --git a/src/playlist-loader/dash-media-playlist-loader.js b/src/playlist-loader/dash-media-playlist-loader.js index a585fb7f8..41d3bb1a9 100644 --- a/src/playlist-loader/dash-media-playlist-loader.js +++ b/src/playlist-loader/dash-media-playlist-loader.js @@ -3,7 +3,7 @@ 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 {segmentXhrHeaders} from '../xhr'; import {mergeMedia, forEachMediaGroup} from './utils.js'; export const getMediaAccessor = function(mainManifest, uri) { From e1d58f71d1e15290e58bfc0c0732c06348faf5bf Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Mon, 1 Nov 2021 15:36:33 -0400 Subject: [PATCH 17/27] fix tests for from refactor --- .../dash-main-playlist-loader.js | 16 +++- .../dash-media-playlist-loader.js | 60 +++++--------- src/playlist-loader/utils.js | 28 ++++++- .../dash-media-playlist-loader.test.js | 47 ++++++++--- .../hls-media-playlist-loader.test.js | 78 ------------------ test/playlist-loader/utils.test.js | 81 ++++++++++++++++++- 6 files changed, 177 insertions(+), 133 deletions(-) diff --git a/src/playlist-loader/dash-main-playlist-loader.js b/src/playlist-loader/dash-main-playlist-loader.js index 281017651..3c2e4e2a8 100644 --- a/src/playlist-loader/dash-main-playlist-loader.js +++ b/src/playlist-loader/dash-main-playlist-loader.js @@ -1,7 +1,7 @@ import PlaylistLoader from './playlist-loader.js'; import {resolveUrl} from '../resolve-url'; import {parse as parseMpd, parseUTCTiming} from 'mpd-parser'; -import {mergeManifest} from './utils.js'; +import {mergeManifest, forEachPlaylist} from './utils.js'; class DashMainPlaylistLoader extends PlaylistLoader { constructor(uri, options) { @@ -13,6 +13,16 @@ class DashMainPlaylistLoader extends PlaylistLoader { this.setMediaRefreshTimeout_ = this.setMediaRefreshTimeout_.bind(this); } + playlists() { + const playlists = []; + + forEachPlaylist(this.manifest_, (media) => { + playlists.push(media); + }); + + return playlists; + } + parseManifest_(manifestString, callback) { this.syncClientServerClock_(manifestString, (clientOffset) => { const parsedManifest = parseMpd(manifestString, { @@ -22,10 +32,10 @@ class DashMainPlaylistLoader extends PlaylistLoader { }); // merge everything except for playlists, they will merge themselves - const main = mergeManifest(this.manifest_, parsedManifest, ['playlists']); + const mergeResult = mergeManifest(this.manifest_, parsedManifest, ['playlists']); // always trigger updated, as playlists will have to update themselves - callback(main, true); + callback(mergeResult.manifest, true); }); } diff --git a/src/playlist-loader/dash-media-playlist-loader.js b/src/playlist-loader/dash-media-playlist-loader.js index 41d3bb1a9..661fa0fa7 100644 --- a/src/playlist-loader/dash-media-playlist-loader.js +++ b/src/playlist-loader/dash-media-playlist-loader.js @@ -1,47 +1,24 @@ import PlaylistLoader from './playlist-loader.js'; -import containerRequest from './util/container-request.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, forEachMediaGroup} from './utils.js'; +import {mergeMedia, forEachPlaylist} from './utils.js'; export const getMediaAccessor = function(mainManifest, uri) { - if (!mainManifest || !mainManifest.playlists || !mainManifest.playlists.length) { - return; - } - for (let i = 0; i < mainManifest.playlists.length; i++) { - const media = mainManifest.playlists[i]; + let result; + forEachPlaylist(mainManifest, function(media, index, array) { if (media.uri === uri) { - return { - get: () => mainManifest.playlists[i], + result = { + get: () => array[index], set: (v) => { - mainManifest.playlists[i] = v; + array[index] = v; } }; - } - } - - let result; - forEachMediaGroup(mainManifest, function(properties, type, group, label) { - if (!properties.playlists) { - return; - } - - for (let i = 0; i < properties.playlists; i++) { - const media = properties.playlists[i]; - - if (media.uri === uri) { - result = { - get: () => properties.playlists[i], - set: (v) => { - properties.playlists[i] = v; - } - }; - return true; - } + return true; } }); @@ -85,7 +62,6 @@ class DashMediaPlaylistLoader extends PlaylistLoader { get: mediaAccessor.get, set: mediaAccessor.set, writeable: true, - configurable: true, enumerable: true }); @@ -184,17 +160,25 @@ class DashMediaPlaylistLoader extends PlaylistLoader { } start() { - if (!this.started_) { - this.started_ = true; - this.onMainUpdated_(); + if (this.started_) { + return; } + + this.started_ = true; + this.onMainUpdated_(); } stop() { - if (this.started_) { - this.started_ = false; - this.manifest_ = null; + if (!this.started_) { + return; } + // redefine the getters and setters. + Object.defineProperty(this, 'manifest_', { + value: null, + writeable: true, + enumerable: true + }); + super.stop(); } dispose() { diff --git a/src/playlist-loader/utils.js b/src/playlist-loader/utils.js index a93d7f64a..49ab40b7f 100644 --- a/src/playlist-loader/utils.js +++ b/src/playlist-loader/utils.js @@ -186,11 +186,37 @@ export const forEachMediaGroup = (mainManifest, callback) => { } }; +export const forEachPlaylist = function(mainManifest, callback) { + if (mainManifest && 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; + } + } + }); +}; + export const mergeManifest = function(a, b, excludeKeys) { excludeKeys = excludeKeys || []; let updated = !a; - const mergedManifest = {}; + const mergedManifest = b; a = a || {}; b = b || {}; diff --git a/test/playlist-loader/dash-media-playlist-loader.test.js b/test/playlist-loader/dash-media-playlist-loader.test.js index 297454db7..768a59ec3 100644 --- a/test/playlist-loader/dash-media-playlist-loader.test.js +++ b/test/playlist-loader/dash-media-playlist-loader.test.js @@ -49,7 +49,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { QUnit.module('#start()'); QUnit.test('multiple calls do nothing', function(assert) { - this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.manifest().playlists[0].uri, { + this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.playlists()[0].uri, { vhs: this.fakeVhs, mainPlaylistLoader: this.mainPlaylistLoader }); @@ -72,7 +72,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { QUnit.module('#stop()'); QUnit.test('multiple calls do nothing', function(assert) { - this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.manifest().playlists[0].uri, { + this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.playlists()[0].uri, { vhs: this.fakeVhs, mainPlaylistLoader: this.mainPlaylistLoader }); @@ -94,7 +94,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { QUnit.module('#onMainUpdated_()'); QUnit.test('called via updated event on mainPlaylistLoader', function(assert) { - this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.manifest().playlists[0].uri, { + this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.playlists()[0].uri, { vhs: this.fakeVhs, mainPlaylistLoader: this.mainPlaylistLoader }); @@ -111,7 +111,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { }); QUnit.test('does nothing if not started', function(assert) { - this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.manifest().playlists[0].uri, { + this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.playlists()[0].uri, { vhs: this.fakeVhs, mainPlaylistLoader: this.mainPlaylistLoader }); @@ -122,7 +122,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { }); QUnit.test('triggers updated without oldManifest', function(assert) { - const media = this.mainPlaylistLoader.manifest().playlists[0]; + const media = this.mainPlaylistLoader.playlists()[0]; this.loader = new DashMediaPlaylistLoader(media.uri, { vhs: this.fakeVhs, @@ -137,7 +137,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { this.loader.started_ = true; this.loader.onMainUpdated_(); - assert.equal(this.loader.manifest_, media, 'manifest set as expected'); + assert.equal(this.loader.manifest(), this.mainPlaylistLoader.playlists()[0], 'manifest set as expected'); assert.true(updatedTriggered, 'updatedTriggered'); assert.deepEqual( this.setMediaRefreshTimeCalls, @@ -147,7 +147,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { }); QUnit.test('does not trigger updated if manifest is the same', function(assert) { - const media = this.mainPlaylistLoader.manifest().playlists[0]; + const media = this.mainPlaylistLoader.playlists()[0]; this.loader = new DashMediaPlaylistLoader(media.uri, { vhs: this.fakeVhs, @@ -164,7 +164,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { this.loader.started_ = true; this.loader.onMainUpdated_(); - assert.equal(this.loader.manifest_, media, 'manifest set as expected'); + assert.equal(this.loader.manifest(), this.mainPlaylistLoader.playlists()[0], 'manifest set as expected'); assert.false(updatedTriggered, 'updatedTriggered'); assert.deepEqual( this.setMediaRefreshTimeCalls, @@ -174,7 +174,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { }); QUnit.test('triggers updated if manifest properties changed', function(assert) { - const media = this.mainPlaylistLoader.manifest().playlists[0]; + const media = this.mainPlaylistLoader.playlists()[0]; this.loader = new DashMediaPlaylistLoader(media.uri, { vhs: this.fakeVhs, @@ -193,7 +193,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { this.loader.onMainUpdated_(); - assert.equal(this.loader.manifest_, media, 'manifest set as expected'); + assert.equal(this.loader.manifest(), this.mainPlaylistLoader.playlists()[0], 'manifest set as expected'); assert.true(updatedTriggered, 'updatedTriggered'); assert.deepEqual( this.setMediaRefreshTimeCalls, @@ -203,7 +203,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { }); QUnit.test('triggers updated if segment properties changed', function(assert) { - const media = this.mainPlaylistLoader.manifest().playlists[0]; + const media = this.mainPlaylistLoader.playlists()[0]; this.loader = new DashMediaPlaylistLoader(media.uri, { vhs: this.fakeVhs, @@ -227,7 +227,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { this.loader.onMainUpdated_(); - assert.equal(this.loader.manifest_, media, 'manifest set as expected'); + assert.equal(this.loader.manifest(), this.mainPlaylistLoader.playlists()[0], 'manifest set as expected'); assert.true(updatedTriggered, 'updatedTriggered'); assert.deepEqual( this.setMediaRefreshTimeCalls, @@ -236,5 +236,28 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { ); }); + 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'); + }); + }); diff --git a/test/playlist-loader/hls-media-playlist-loader.test.js b/test/playlist-loader/hls-media-playlist-loader.test.js index 6bbad59c1..7810959f1 100644 --- a/test/playlist-loader/hls-media-playlist-loader.test.js +++ b/test/playlist-loader/hls-media-playlist-loader.test.js @@ -3,7 +3,6 @@ import videojs from 'video.js'; import { default as HlsMediaPlaylistLoader, getAllSegments, - mergeMedia, timeBeforeRefresh } from '../../src/playlist-loader/hls-media-playlist-loader.js'; import {useFakeEnvironment} from '../test-helpers'; @@ -258,82 +257,5 @@ QUnit.module('HLS Media Playlist Loader', function(hooks) { ); }); - 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' - ); - }); - }); diff --git a/test/playlist-loader/utils.test.js b/test/playlist-loader/utils.test.js index e9c9dd072..b69590f1d 100644 --- a/test/playlist-loader/utils.test.js +++ b/test/playlist-loader/utils.test.js @@ -2,7 +2,8 @@ import QUnit from 'qunit'; import { forEachMediaGroup, mergeSegments, - mergeSegment + mergeSegment, + mergeMedia } from '../../src/playlist-loader/utils.js'; import {absoluteUrl} from '../test-helpers.js'; @@ -540,5 +541,83 @@ QUnit.module('Playlist Loader Utils', function(hooks) { 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' + ); + }); + }); From 3050154fd9697b3799285ba927d62e5a99c55fd7 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Mon, 1 Nov 2021 16:28:59 -0400 Subject: [PATCH 18/27] finish sidx tests --- src/playlist-loader/TODO.md | 5 - .../dash-media-playlist-loader.js | 26 +- .../dash-media-playlist-loader.test.js | 243 +++++++++++++++++- 3 files changed, 245 insertions(+), 29 deletions(-) delete mode 100644 src/playlist-loader/TODO.md diff --git a/src/playlist-loader/TODO.md b/src/playlist-loader/TODO.md deleted file mode 100644 index fb90550a0..000000000 --- a/src/playlist-loader/TODO.md +++ /dev/null @@ -1,5 +0,0 @@ - - -* Finish DashMain/Media PlaylistLoader - * Write tests - * verify merging diff --git a/src/playlist-loader/dash-media-playlist-loader.js b/src/playlist-loader/dash-media-playlist-loader.js index 661fa0fa7..123fb6ded 100644 --- a/src/playlist-loader/dash-media-playlist-loader.js +++ b/src/playlist-loader/dash-media-playlist-loader.js @@ -96,20 +96,7 @@ class DashMediaPlaylistLoader extends PlaylistLoader { } const uri = this.manifest_.sidx.resolvedUri; - const parseSidx_ = (error, request) => { - if (error) { - this.error_ = typeof err === 'object' && !(error instanceof Error) ? error : { - status: request.status, - message: 'DASH sidx request error at URL: ' + request.uri, - response: request.response, - // MEDIA_ERR_NETWORK - code: 2 - }; - - this.trigger('error'); - return; - } - + const parseSidx_ = (request, wasRedirected) => { let sidx; try { @@ -121,7 +108,7 @@ class DashMediaPlaylistLoader extends PlaylistLoader { return; } - this.sidx = sidx; + this.sidx_ = sidx; callback(); }; @@ -130,20 +117,23 @@ class DashMediaPlaylistLoader extends PlaylistLoader { this.request_ = null; if (error || !container || container !== 'mp4') { - return parseSidx_(error || { + this.error = { status: request.status, message: `Unsupported ${container || 'unknown'} container type for sidx segment at URL: ${uri}`, blacklistDuration: Infinity, // MEDIA_ERR_NETWORK code: 2 - }, null); + }; + + this.trigger('error'); + 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_(error, { + return parseSidx_({ response: bytes.subarray(offset, offset + length), status: request.status, uri: request.uri diff --git a/test/playlist-loader/dash-media-playlist-loader.test.js b/test/playlist-loader/dash-media-playlist-loader.test.js index 768a59ec3..a629f22b6 100644 --- a/test/playlist-loader/dash-media-playlist-loader.test.js +++ b/test/playlist-loader/dash-media-playlist-loader.test.js @@ -2,9 +2,14 @@ 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} from '../test-helpers'; +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) { @@ -20,7 +25,7 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { this.logLines.push(args.join(' ')); }; - this.mainPlaylistLoader = new DashMainPlaylistLoader('dash-many-codecs.mpd', { + this.mainPlaylistLoader = new DashMainPlaylistLoader('main-manifests.mpd', { vhs: this.fakeVhs }); @@ -32,7 +37,6 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { this.mainPlaylistLoader.start(); - this.requests[0].respond(200, null, testDataManifests['dash-many-codecs']); }); hooks.afterEach(function(assert) { @@ -46,7 +50,11 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { videojs.log.debug = this.oldDebugLog; }); - QUnit.module('#start()'); + 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, { @@ -69,7 +77,11 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { assert.true(this.loader.started_, 'still started'); }); - QUnit.module('#stop()'); + 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, { @@ -91,7 +103,11 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { assert.false(this.loader.started_, 'still stopped'); }); - QUnit.module('#onMainUpdated_()'); + QUnit.module('#onMainUpdated_()', { + beforeEach() { + this.requests.shift().respond(200, null, testDataManifests['dash-many-codecs']); + } + }); QUnit.test('called via updated event on mainPlaylistLoader', function(assert) { this.loader = new DashMediaPlaylistLoader(this.mainPlaylistLoader.playlists()[0].uri, { @@ -259,5 +275,220 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { 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'); + }); + }); From 3933f1a463282d831112a7e43c8afb9c363b21b1 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Tue, 2 Nov 2021 14:50:18 -0400 Subject: [PATCH 19/27] finish testing --- .../dash-main-playlist-loader.js | 44 +-- .../dash-media-playlist-loader.js | 51 ++-- src/playlist-loader/playlist-loader.js | 48 ++- src/playlist-loader/utils.js | 7 +- .../dash-main-playlist-loader.test.js | 218 +++++++++++++- .../dash-media-playlist-loader.test.js | 22 +- test/playlist-loader/playlist-loader.test.js | 30 ++ test/playlist-loader/utils.test.js | 274 +++++++++++++++++- 8 files changed, 612 insertions(+), 82 deletions(-) diff --git a/src/playlist-loader/dash-main-playlist-loader.js b/src/playlist-loader/dash-main-playlist-loader.js index 3c2e4e2a8..987eb9067 100644 --- a/src/playlist-loader/dash-main-playlist-loader.js +++ b/src/playlist-loader/dash-main-playlist-loader.js @@ -40,7 +40,13 @@ class DashMainPlaylistLoader extends PlaylistLoader { } syncClientServerClock_(manifestString, callback) { - const utcTiming = parseUTCTiming(manifestString); + 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 @@ -52,21 +58,18 @@ class DashMainPlaylistLoader extends PlaylistLoader { return callback(utcTiming.value - Date.now()); } - this.makeRequest({ + this.makeRequest_({ uri: resolveUrl(this.uri(), utcTiming.value), - method: utcTiming.method - }, function(request) { - let serverTime; - - if (utcTiming.method === 'HEAD') { - if (!request.responseHeaders || !request.responseHeaders.date) { - // expected date header not preset, fall back to using date header from mpd - this.logger_('warning expected date header from mpd not present, using mpd request time.'); - serverTime = this.lastRequestTime(); - } else { - serverTime = Date.parse(request.responseHeaders.date); - } - } else { + 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); } @@ -77,9 +80,8 @@ class DashMainPlaylistLoader extends PlaylistLoader { // used by dash media playlist loaders in cases where // minimumUpdatePeriod is zero setMediaRefreshTime_(time) { - if (!this.getMediaRefreshTime_()) { - this.setMediaRefreshTimeout_(time); - } + this.mediaRefreshTime_ = time; + this.setMediaRefreshTimeout_(); } getMediaRefreshTime_() { @@ -89,7 +91,7 @@ class DashMainPlaylistLoader extends PlaylistLoader { // can happen when a live video becomes VOD. We do not have // a media refresh time. if (typeof minimumUpdatePeriod !== 'number' || minimumUpdatePeriod < 0) { - return; + return null; } // If the minimumUpdatePeriod has a value of 0, that indicates that the current @@ -99,9 +101,9 @@ class DashMainPlaylistLoader extends PlaylistLoader { // 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 media there target duration. + // the medias target duration. if (minimumUpdatePeriod === 0) { - return; + return this.mediaRefreshTime_; } return minimumUpdatePeriod; diff --git a/src/playlist-loader/dash-media-playlist-loader.js b/src/playlist-loader/dash-media-playlist-loader.js index 123fb6ded..83bf1acab 100644 --- a/src/playlist-loader/dash-media-playlist-loader.js +++ b/src/playlist-loader/dash-media-playlist-loader.js @@ -51,22 +51,23 @@ class DashMediaPlaylistLoader extends PlaylistLoader { 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() ); - // redefine the getters and setters. - Object.defineProperty(this, 'manifest_', { - get: mediaAccessor.get, - set: mediaAccessor.set, - writeable: true, - enumerable: true - }); + if (!mediaAccessor) { + this.triggerError_('could not find playlist on mainPlaylistLoader'); + return; + } // use them - const newMedia = this.manifest_; + const newMedia = this.manifest_ = mediaAccessor.get(); this.requestSidx_(() => { if (newMedia.sidx && this.sidx_) { @@ -81,7 +82,9 @@ class DashMediaPlaylistLoader extends PlaylistLoader { uri: this.mainPlaylistLoader_.uri() }); - this.manifest_ = media; + // set the newly merged media on main + mediaAccessor.set(media); + this.manifest_ = mediaAccessor.get(); if (updated) { this.mainPlaylistLoader_.setMediaRefreshTime_(this.manifest().targetDuration * 1000); @@ -103,8 +106,7 @@ class DashMediaPlaylistLoader extends PlaylistLoader { sidx = parseSidx(toUint8(request.response).subarray(8)); } catch (e) { // sidx parsing failed. - this.error_ = e; - this.trigger('error'); + this.triggerError_(e); return; } @@ -117,15 +119,12 @@ class DashMediaPlaylistLoader extends PlaylistLoader { this.request_ = null; if (error || !container || container !== 'mp4') { - this.error = { - status: request.status, - message: `Unsupported ${container || 'unknown'} container type for sidx segment at URL: ${uri}`, - blacklistDuration: Infinity, - // MEDIA_ERR_NETWORK - code: 2 - }; - - this.trigger('error'); + if (error) { + this.triggerError_(error); + } else { + container = container || 'unknown'; + this.triggerError_(`Unsupported ${container} container type for sidx segment at URL: ${uri}`); + } return; } @@ -145,7 +144,7 @@ class DashMediaPlaylistLoader extends PlaylistLoader { uri, responseType: 'arraybuffer', headers: segmentXhrHeaders({byterange: this.manifest_.sidx.byterange}) - }, parseSidx_, false); + }, parseSidx_); }); } @@ -162,12 +161,10 @@ class DashMediaPlaylistLoader extends PlaylistLoader { if (!this.started_) { return; } - // redefine the getters and setters. - Object.defineProperty(this, 'manifest_', { - value: null, - writeable: true, - enumerable: true - }); + + this.manifest_ = null; + // reset media refresh time + this.mainPlaylistLoader_.setMediaRefreshTime_(null); super.stop(); } diff --git a/src/playlist-loader/playlist-loader.js b/src/playlist-loader/playlist-loader.js index a4a30469d..d90358902 100644 --- a/src/playlist-loader/playlist-loader.js +++ b/src/playlist-loader/playlist-loader.js @@ -70,13 +70,19 @@ class PlaylistLoader extends videojs.EventTarget { parseManifest_(manifestText, callback) {} // make a request and do custom error handling - makeRequest_(options, callback, handleErrors = true) { + makeRequest_(options, callback) { if (!this.started_) { - this.error_ = {message: 'makeRequest_ cannot be called before started!'}; - this.trigger('error'); + 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 @@ -87,33 +93,24 @@ class PlaylistLoader extends videojs.EventTarget { // successful or errored requests are finished. this.request_ = null; - if (error) { - this.error_ = typeof error === 'object' && !(error instanceof Error) ? error : { - status: request.status, - message: `Playlist request error at URI ${request.uri}`, - response: request.response, - code: (request.status >= 500) ? 4 : 2 - }; + const wasRedirected = Boolean(this.options_.handleManifestRedirects && + request.responseURL !== xhrOptions.uri); - this.trigger('error'); + if (error && handleErrors) { + this.triggerError_(`Request error at URI ${request.uri}`); return; } - const wasRedirected = Boolean(this.options_.handleManifestRedirects && - request.responseURL !== xhrOptions.uri); - - callback(request, wasRedirected); + callback(request, wasRedirected, error); }); } - requestError_(error, request) { - this.error_ = typeof error === 'object' && !(error instanceof Error) ? error : { - status: request.status, - message: `Playlist request error at URI ${request.uri}`, - response: request.response, - code: (request.status >= 500) ? 4 : 2 - }; + triggerError_(error) { + if (typeof error === 'string') { + error = {message: error}; + } + this.error_ = error; this.trigger('error'); } @@ -148,14 +145,13 @@ class PlaylistLoader extends videojs.EventTarget { } } - setMediaRefreshTimeout_(time) { + setMediaRefreshTimeout_() { // do nothing if disposed if (this.isDisposed_) { return; } - if (typeof time !== 'number') { - time = this.getMediaRefreshTime_(); - } + const time = this.getMediaRefreshTime_(); + this.clearMediaRefreshTimeout_(); if (typeof time !== 'number') { diff --git a/src/playlist-loader/utils.js b/src/playlist-loader/utils.js index 49ab40b7f..3eece0006 100644 --- a/src/playlist-loader/utils.js +++ b/src/playlist-loader/utils.js @@ -156,7 +156,7 @@ const MEDIA_GROUP_TYPES = ['AUDIO', 'SUBTITLES']; * value will stop the loop. */ export const forEachMediaGroup = (mainManifest, callback) => { - if (!mainManifest.mediaGroups) { + if (!mainManifest || !mainManifest.mediaGroups) { return; } @@ -187,7 +187,10 @@ export const forEachMediaGroup = (mainManifest, callback) => { }; export const forEachPlaylist = function(mainManifest, callback) { - if (mainManifest && mainManifest.playlists) { + if (!mainManifest) { + return; + } + if (mainManifest.playlists) { for (let i = 0; i < mainManifest.playlists.length; i++) { const stop = callback(mainManifest.playlists[i], i, mainManifest.playlists); diff --git a/test/playlist-loader/dash-main-playlist-loader.test.js b/test/playlist-loader/dash-main-playlist-loader.test.js index 8264f901b..f932ad9b9 100644 --- a/test/playlist-loader/dash-main-playlist-loader.test.js +++ b/test/playlist-loader/dash-main-playlist-loader.test.js @@ -3,7 +3,7 @@ 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'; +import testDataManifests from 'create-test-data!manifests'; QUnit.module('Dash Main Playlist Loader', function(hooks) { hooks.beforeEach(function(assert) { @@ -28,13 +28,223 @@ QUnit.module('Dash Main Playlist Loader', function(hooks) { videojs.log.debug = this.oldDebugLog; }); - QUnit.module('#start()'); + // Since playlists is mostly a wrapper around forEachPlaylists + // most of the tests are located there. + QUnit.module('#playlists()'); - QUnit.test('multiple calls do nothing', function(assert) { + 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 index a629f22b6..97ad911c4 100644 --- a/test/playlist-loader/dash-media-playlist-loader.test.js +++ b/test/playlist-loader/dash-media-playlist-loader.test.js @@ -36,7 +36,6 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { }; this.mainPlaylistLoader.start(); - }); hooks.afterEach(function(assert) { @@ -95,6 +94,11 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { 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(); @@ -109,6 +113,22 @@ QUnit.module('Dash Media Playlist Loader', function(hooks) { } }); + 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, diff --git a/test/playlist-loader/playlist-loader.test.js b/test/playlist-loader/playlist-loader.test.js index c5176efbe..5c323255a 100644 --- a/test/playlist-loader/playlist-loader.test.js +++ b/test/playlist-loader/playlist-loader.test.js @@ -433,6 +433,36 @@ QUnit.module('New Playlist Loader', function(hooks) { assert.true(errorTriggered, 'error was triggered'); }); + QUnit.test('handleErrors: false causes errors to be passed along, not triggered', 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', handleErrors: false}, function(request, wasRedirected, error) { + assert.ok(error, 'error was passed in'); + }); + + this.requests[0].respond(404, null, 'bad request foo bar'); + + const expectedError = { + code: 2, + message: 'Playlist request error at URI bar.uri', + response: 'bad request foo bar', + status: 404 + }; + + assert.deepEqual(this.loader.error(), expectedError, 'expected error'); + assert.equal(this.loader.request(), null, 'no request'); + assert.true(errorTriggered, 'error was triggered'); + }); + QUnit.module('#stop()'); QUnit.test('only stops things if started', function(assert) { diff --git a/test/playlist-loader/utils.test.js b/test/playlist-loader/utils.test.js index b69590f1d..9764de5e2 100644 --- a/test/playlist-loader/utils.test.js +++ b/test/playlist-loader/utils.test.js @@ -3,7 +3,9 @@ import { forEachMediaGroup, mergeSegments, mergeSegment, - mergeMedia + mergeMedia, + forEachPlaylist, + mergeManifest } from '../../src/playlist-loader/utils.js'; import {absoluteUrl} from '../test-helpers.js'; @@ -11,6 +13,17 @@ 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 = {}; @@ -619,5 +632,264 @@ QUnit.module('Playlist Loader Utils', function(hooks) { ); }); + 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.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' + ); + }); }); From 1506eb1ecabcf78dac44f25ebc70955df1da9502 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Tue, 2 Nov 2021 14:58:52 -0400 Subject: [PATCH 20/27] fix test errors caused by error refactor --- test/playlist-loader/playlist-loader.test.js | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/test/playlist-loader/playlist-loader.test.js b/test/playlist-loader/playlist-loader.test.js index 5c323255a..6b11ac659 100644 --- a/test/playlist-loader/playlist-loader.test.js +++ b/test/playlist-loader/playlist-loader.test.js @@ -392,10 +392,7 @@ QUnit.module('New Playlist Loader', function(hooks) { this.requests[0].respond(505, null, 'bad request foo bar'); const expectedError = { - code: 4, - message: 'Playlist request error at URI bar.uri', - response: 'bad request foo bar', - status: 505 + message: 'Request error at URI bar.uri' }; assert.deepEqual(this.loader.error(), expectedError, 'expected error'); @@ -422,10 +419,7 @@ QUnit.module('New Playlist Loader', function(hooks) { this.requests[0].respond(404, null, 'bad request foo bar'); const expectedError = { - code: 2, - message: 'Playlist request error at URI bar.uri', - response: 'bad request foo bar', - status: 404 + message: 'Request error at URI bar.uri' }; assert.deepEqual(this.loader.error(), expectedError, 'expected error'); @@ -452,10 +446,7 @@ QUnit.module('New Playlist Loader', function(hooks) { this.requests[0].respond(404, null, 'bad request foo bar'); const expectedError = { - code: 2, - message: 'Playlist request error at URI bar.uri', - response: 'bad request foo bar', - status: 404 + message: 'Request error at URI bar.uri' }; assert.deepEqual(this.loader.error(), expectedError, 'expected error'); From 6ee8886f28962d8d63d43a47cc07d729e3d46e99 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Tue, 2 Nov 2021 15:06:51 -0400 Subject: [PATCH 21/27] another test fix --- test/playlist-loader/playlist-loader.test.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/test/playlist-loader/playlist-loader.test.js b/test/playlist-loader/playlist-loader.test.js index 6b11ac659..896f07776 100644 --- a/test/playlist-loader/playlist-loader.test.js +++ b/test/playlist-loader/playlist-loader.test.js @@ -428,7 +428,7 @@ QUnit.module('New Playlist Loader', function(hooks) { }); QUnit.test('handleErrors: false causes errors to be passed along, not triggered', function(assert) { - assert.expect(5); + assert.expect(6); this.loader = new PlaylistLoader('foo.uri', { vhs: this.fakeVhs }); @@ -445,13 +445,9 @@ QUnit.module('New Playlist Loader', function(hooks) { 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.notOk(this.loader.error(), 'no error'); assert.equal(this.loader.request(), null, 'no request'); - assert.true(errorTriggered, 'error was triggered'); + assert.false(errorTriggered, 'error was triggered'); }); QUnit.module('#stop()'); From 3b9de24d8eb95e9e1ba780a44603f0f4173f87d1 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Tue, 2 Nov 2021 16:22:20 -0400 Subject: [PATCH 22/27] jsdocs --- .../dash-main-playlist-loader.js | 73 +++++++- .../dash-media-playlist-loader.js | 41 +++++ .../hls-main-playlist-loader.js | 6 + .../hls-media-playlist-loader.js | 49 +++++- src/playlist-loader/playlist-loader.js | 163 +++++++++++++++++- src/playlist-loader/utils.js | 95 +++++++++- src/util/deep-equal.js | 22 +++ 7 files changed, 434 insertions(+), 15 deletions(-) diff --git a/src/playlist-loader/dash-main-playlist-loader.js b/src/playlist-loader/dash-main-playlist-loader.js index 987eb9067..e3cfe2b00 100644 --- a/src/playlist-loader/dash-main-playlist-loader.js +++ b/src/playlist-loader/dash-main-playlist-loader.js @@ -3,16 +3,40 @@ 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.sidxMapping_ = {}; - this.mediaList_ = options.mediaList; 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 = []; @@ -23,6 +47,18 @@ class DashMainPlaylistLoader extends PlaylistLoader { 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, { @@ -39,6 +75,17 @@ class DashMainPlaylistLoader extends PlaylistLoader { }); } + /** + * 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; @@ -77,13 +124,33 @@ class DashMainPlaylistLoader extends PlaylistLoader { }); } - // used by dash media playlist loaders in cases where - // minimumUpdatePeriod is zero + /** + * 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; diff --git a/src/playlist-loader/dash-media-playlist-loader.js b/src/playlist-loader/dash-media-playlist-loader.js index 83bf1acab..898d01f65 100644 --- a/src/playlist-loader/dash-media-playlist-loader.js +++ b/src/playlist-loader/dash-media-playlist-loader.js @@ -25,7 +25,30 @@ export const getMediaAccessor = function(mainManifest, uri) { 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; @@ -47,6 +70,15 @@ class DashMediaPlaylistLoader extends PlaylistLoader { 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; @@ -93,6 +125,15 @@ class DashMediaPlaylistLoader extends PlaylistLoader { }); } + /** + * 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(); diff --git a/src/playlist-loader/hls-main-playlist-loader.js b/src/playlist-loader/hls-main-playlist-loader.js index 38c17e76e..909a1529c 100644 --- a/src/playlist-loader/hls-main-playlist-loader.js +++ b/src/playlist-loader/hls-main-playlist-loader.js @@ -1,6 +1,12 @@ 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({ diff --git a/src/playlist-loader/hls-media-playlist-loader.js b/src/playlist-loader/hls-media-playlist-loader.js index 4ca71c2a3..4874dc80e 100644 --- a/src/playlist-loader/hls-media-playlist-loader.js +++ b/src/playlist-loader/hls-media-playlist-loader.js @@ -5,10 +5,12 @@ import {mergeMedia} from './utils.js'; /** * Calculates the time to wait before refreshing a live playlist * - * @param {Object} media - * The current media + * @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 */ @@ -26,9 +28,17 @@ export const timeBeforeRefresh = function(manifest, update) { 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. +/** + * 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); @@ -52,6 +62,17 @@ const clonePreloadSegment = (preloadSegment) => { 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; @@ -97,16 +118,32 @@ export const getAllSegments = function(manifest) { 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, as it + // 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) { diff --git a/src/playlist-loader/playlist-loader.js b/src/playlist-loader/playlist-loader.js index d90358902..547242867 100644 --- a/src/playlist-loader/playlist-loader.js +++ b/src/playlist-loader/playlist-loader.js @@ -2,7 +2,41 @@ 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); @@ -21,31 +55,74 @@ class PlaylistLoader extends videojs.EventTarget { 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_; } - refreshManifest_(callback) { + /** + * 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; @@ -67,9 +144,47 @@ class PlaylistLoader extends videojs.EventTarget { }); } + /** + * 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) {} - // make a request and do custom error handling + /** + * 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!'); @@ -105,6 +220,14 @@ class PlaylistLoader extends videojs.EventTarget { }); } + /** + * 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}; @@ -112,8 +235,12 @@ class PlaylistLoader extends videojs.EventTarget { this.error_ = error; this.trigger('error'); + this.stop(); } + /** + * Start the loader + */ start() { if (!this.started_) { this.started_ = true; @@ -121,6 +248,9 @@ class PlaylistLoader extends videojs.EventTarget { } } + /** + * Stop the loader + */ stop() { if (this.started_) { this.started_ = false; @@ -129,7 +259,9 @@ class PlaylistLoader extends videojs.EventTarget { } } - // stop a request if one exists. + /** + * Stop any requests on the loader + */ stopRequest() { if (this.request_) { this.request_.onreadystatechange = null; @@ -138,6 +270,11 @@ class PlaylistLoader extends videojs.EventTarget { } } + /** + * clear the media refresh timeout + * + * @private + */ clearMediaRefreshTimeout_() { if (this.refreshTimeout_) { window.clearTimeout(this.refreshTimeout_); @@ -145,6 +282,13 @@ class PlaylistLoader extends videojs.EventTarget { } } + /** + * Set or clear the media refresh timeout based on + * what getMediaRefreshTime_ returns. + * + * @listens {PlaylistLoader#updated} + * @private + */ setMediaRefreshTimeout_() { // do nothing if disposed if (this.isDisposed_) { @@ -166,10 +310,23 @@ class PlaylistLoader extends videojs.EventTarget { }, 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(); diff --git a/src/playlist-loader/utils.js b/src/playlist-loader/utils.js index 3eece0006..d1a982668 100644 --- a/src/playlist-loader/utils.js +++ b/src/playlist-loader/utils.js @@ -2,6 +2,19 @@ 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 @@ -43,10 +56,14 @@ const resolveSegmentUris = function(segment, baseUri) { * Returns a new segment object with properties and * the parts array merged. * - * @param {Object} a the old segment - * @param {Object} b the new segment + * @param {Object} a + * The old segment * - * @return {Object} the merged 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; @@ -103,6 +120,30 @@ export const mergeSegment = function(a, b) { 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 || []; @@ -150,6 +191,7 @@ const MEDIA_GROUP_TYPES = ['AUDIO', 'SUBTITLES']; * * @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 @@ -186,6 +228,18 @@ export const forEachMediaGroup = (mainManifest, callback) => { } }; +/** + * 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; @@ -215,6 +269,23 @@ export const forEachPlaylist = function(mainManifest, callback) { }); }; +/** + * 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 || []; @@ -256,6 +327,24 @@ export const mergeManifest = function(a, b, excludeKeys) { 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']); diff --git a/src/util/deep-equal.js b/src/util/deep-equal.js index eee5b96ce..d52478cba 100644 --- a/src/util/deep-equal.js +++ b/src/util/deep-equal.js @@ -1,6 +1,28 @@ +/** + * 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) { From 43693745a6e3c8d2049b8c036f0adb5597f21d79 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Tue, 2 Nov 2021 16:30:49 -0400 Subject: [PATCH 23/27] coverage updates --- .../dash-media-playlist-loader.js | 4 ++ test/playlist-loader/utils.test.js | 47 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/playlist-loader/dash-media-playlist-loader.js b/src/playlist-loader/dash-media-playlist-loader.js index 898d01f65..3c27aea24 100644 --- a/src/playlist-loader/dash-media-playlist-loader.js +++ b/src/playlist-loader/dash-media-playlist-loader.js @@ -59,6 +59,10 @@ class DashMediaPlaylistLoader extends PlaylistLoader { 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 diff --git a/test/playlist-loader/utils.test.js b/test/playlist-loader/utils.test.js index 9764de5e2..8c4309b1f 100644 --- a/test/playlist-loader/utils.test.js +++ b/test/playlist-loader/utils.test.js @@ -786,6 +786,53 @@ QUnit.module('Playlist Loader Utils', function(hooks) { 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) { From c53d4642a0fa11eb20a8140eeebc68a36b86795f Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Tue, 2 Nov 2021 16:43:57 -0400 Subject: [PATCH 24/27] ie 11 fixes --- src/playlist-loader/playlist-loader.js | 2 +- src/playlist-loader/utils.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/playlist-loader/playlist-loader.js b/src/playlist-loader/playlist-loader.js index 547242867..c1d9b07b6 100644 --- a/src/playlist-loader/playlist-loader.js +++ b/src/playlist-loader/playlist-loader.js @@ -39,7 +39,7 @@ class PlaylistLoader extends videojs.EventTarget { */ constructor(uri, options = {}) { super(); - this.logger_ = logger(this.constructor.name); + this.logger_ = logger(this.constructor.name || 'PlaylistLoader'); this.uri_ = uri; this.options_ = options; this.manifest_ = options.manifest || null; diff --git a/src/playlist-loader/utils.js b/src/playlist-loader/utils.js index d1a982668..2c6f2ab7a 100644 --- a/src/playlist-loader/utils.js +++ b/src/playlist-loader/utils.js @@ -15,7 +15,7 @@ import deepEqual from '../util/deep-equal.js'; * @return {Object} * The segment with resolved uris. */ -const resolveSegmentUris = function(segment, baseUri) { +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) { From c1449d2c9e8d4d1dcb7a45d7b7410d6c6ed8aa57 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Wed, 3 Nov 2021 16:47:27 -0400 Subject: [PATCH 25/27] remove sidxMapping --- src/playlist-loader/dash-main-playlist-loader.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/playlist-loader/dash-main-playlist-loader.js b/src/playlist-loader/dash-main-playlist-loader.js index e3cfe2b00..0f7866242 100644 --- a/src/playlist-loader/dash-main-playlist-loader.js +++ b/src/playlist-loader/dash-main-playlist-loader.js @@ -25,7 +25,6 @@ class DashMainPlaylistLoader extends PlaylistLoader { constructor(uri, options) { super(uri, options); this.clientOffset_ = null; - this.sidxMapping_ = {}; this.clientClockOffset_ = null; this.setMediaRefreshTimeout_ = this.setMediaRefreshTimeout_.bind(this); } @@ -63,8 +62,7 @@ class DashMainPlaylistLoader extends PlaylistLoader { this.syncClientServerClock_(manifestString, (clientOffset) => { const parsedManifest = parseMpd(manifestString, { manifestUri: this.uri_, - clientOffset, - sidxMapping: this.sidxMapping_ + clientOffset }); // merge everything except for playlists, they will merge themselves From c8cb6173fb0c97f1fccd4ca4a176491b3a68a002 Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Wed, 3 Nov 2021 16:49:19 -0400 Subject: [PATCH 26/27] do not merge mediaGroups either --- src/playlist-loader/dash-main-playlist-loader.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/playlist-loader/dash-main-playlist-loader.js b/src/playlist-loader/dash-main-playlist-loader.js index 0f7866242..d7d2bbff2 100644 --- a/src/playlist-loader/dash-main-playlist-loader.js +++ b/src/playlist-loader/dash-main-playlist-loader.js @@ -66,7 +66,7 @@ class DashMainPlaylistLoader extends PlaylistLoader { }); // merge everything except for playlists, they will merge themselves - const mergeResult = mergeManifest(this.manifest_, parsedManifest, ['playlists']); + const mergeResult = mergeManifest(this.manifest_, parsedManifest, ['playlists', 'mediaGroups']); // always trigger updated, as playlists will have to update themselves callback(mergeResult.manifest, true); From c258254d1c34c195fb696bfde925055ef66fe7ae Mon Sep 17 00:00:00 2001 From: brandonocasey Date: Wed, 3 Nov 2021 16:56:42 -0400 Subject: [PATCH 27/27] add jsdocs for getMediaAccessor --- src/playlist-loader/dash-media-playlist-loader.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/playlist-loader/dash-media-playlist-loader.js b/src/playlist-loader/dash-media-playlist-loader.js index 3c27aea24..3856fef1a 100644 --- a/src/playlist-loader/dash-media-playlist-loader.js +++ b/src/playlist-loader/dash-media-playlist-loader.js @@ -6,8 +6,21 @@ 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; + let result = null; forEachPlaylist(mainManifest, function(media, index, array) { if (media.uri === uri) {