diff --git a/src/content-steering-controller.js b/src/content-steering-controller.js index 64ab48663..4596ed4fa 100644 --- a/src/content-steering-controller.js +++ b/src/content-steering-controller.js @@ -66,26 +66,27 @@ class SteeringManifest { * https://datatracker.ietf.org/doc/draft-pantos-hls-rfc8216bis/ section 4.4.6.6. * DASH: https://dashif.org/docs/DASH-IF-CTS-00XX-Content-Steering-Community-Review.pdf * - * @param {Object} segmentLoader a reference to the mainSegmentLoader + * @param {function} xhr for making a network request from the browser. + * @param {function} bandwidth for fetching the current bandwidth from the main segment loader. */ export default class ContentSteeringController extends videojs.EventTarget { - // pass a segment loader reference for throughput rate and xhr - constructor(segmentLoader) { + constructor(xhr, bandwidth) { super(); this.currentPathway = null; this.defaultPathway = null; this.queryBeforeStart = null; this.availablePathways_ = new Set(); - // TODO: Implement exclusion. this.excludedPathways_ = new Set(); this.steeringManifest = new SteeringManifest(); this.proxyServerUrl_ = null; this.manifestType_ = null; this.ttlTimeout_ = null; this.request_ = null; - this.mainSegmentLoader_ = segmentLoader; + this.excludedSteeringManifestURLs = new Set(); this.logger_ = logger('Content Steering'); + this.xhr_ = xhr; + this.getBandwidth_ = bandwidth; } /** @@ -109,57 +110,93 @@ export default class ContentSteeringController extends videojs.EventTarget { this.decodeDataUriManifest_(steeringUri.substring(steeringUri.indexOf(',') + 1)); return; } - this.steeringManifest.reloadUri = resolveUrl(baseUrl, steeringUri); + // With DASH queryBeforeStart, we want to use the steeringUri as soon as possible for the request. + this.steeringManifest.reloadUri = this.queryBeforeStart ? steeringUri : resolveUrl(baseUrl, steeringUri); // pathwayId is HLS defaultServiceLocation is DASH this.defaultPathway = steeringTag.pathwayId || steeringTag.defaultServiceLocation; // currently only DASH supports the following properties on tags. - if (this.manifestType_ === 'DASH') { - this.queryBeforeStart = steeringTag.queryBeforeStart || false; - this.proxyServerUrl_ = steeringTag.proxyServerURL; - } + this.queryBeforeStart = steeringTag.queryBeforeStart || false; + this.proxyServerUrl_ = steeringTag.proxyServerURL || null; // trigger a steering event if we have a pathway from the content steering tag. // this tells VHS which segment pathway to start with. - if (this.defaultPathway) { + // If queryBeforeStart is true we need to wait for the steering manifest response. + if (this.defaultPathway && !this.queryBeforeStart) { this.trigger('content-steering'); } + + if (this.queryBeforeStart) { + this.requestSteeringManifest(this.steeringManifest.reloadUri); + } } /** * Requests the content steering manifest and parse the response. This should only be called after * assignTagProperties was called with a content steering tag. + * + * @param {string} initialUri The optional uri to make the request with. + * If set, the request should be made with exactly what is passed in this variable. + * This scenario is specific to DASH when the queryBeforeStart parameter is true. + * This scenario should only happen once on initalization. */ - requestSteeringManifest() { - // add parameters to the steering uri + requestSteeringManifest(initialUri) { const reloadUri = this.steeringManifest.reloadUri; + + if (!initialUri && !reloadUri) { + return; + } + // We currently don't support passing MPD query parameters directly to the content steering URL as this requires // ExtUrlQueryInfo tag support. See the DASH content steering spec section 8.1. - const uri = this.proxyServerUrl_ ? this.setProxyServerUrl_(reloadUri) : this.setSteeringParams_(reloadUri); - this.request_ = this.mainSegmentLoader_.vhs_.xhr({ + // This request URI accounts for manifest URIs that have been excluded. + const uri = initialUri || this.getRequestURI(reloadUri); + + // If there are no valid manifest URIs, we should stop content steering. + if (!uri) { + this.logger_('No valid content steering manifest URIs. Stopping content steering.'); + this.trigger('error'); + this.dispose(); + return; + } + + this.request_ = this.xhr_({ uri - }, (error) => { - // TODO: HLS CASES THAT NEED ADDRESSED: - // If the client receives HTTP 410 Gone in response to a manifest request, - // it MUST NOT issue another request for that URI for the remainder of the - // playback session. It MAY continue to use the most-recently obtained set - // of Pathways. - // If the client receives HTTP 429 Too Many Requests with a Retry-After - // header in response to a manifest request, it SHOULD wait until the time - // specified by the Retry-After header to reissue the request. + }, (error, errorInfo) => { if (error) { - // TODO: HLS RETRY CASE: + // If the client receives HTTP 410 Gone in response to a manifest request, + // it MUST NOT issue another request for that URI for the remainder of the + // playback session. It MAY continue to use the most-recently obtained set + // of Pathways. + if (errorInfo.status === 410) { + this.logger_(`manifest request 410 ${error}.`); + this.logger_(`There will be no more content steering requests to ${uri} this session.`); + + this.excludedSteeringManifestURLs.add(uri); + return; + } + // If the client receives HTTP 429 Too Many Requests with a Retry-After + // header in response to a manifest request, it SHOULD wait until the time + // specified by the Retry-After header to reissue the request. + if (errorInfo.status === 429) { + const retrySeconds = errorInfo.responseHeaders['retry-after']; + + this.logger_(`manifest request 429 ${error}.`); + this.logger_(`content steering will retry in ${retrySeconds} seconds.`); + this.startTTLTimeout_(parseInt(retrySeconds, 10)); + return; + } // If the Steering Manifest cannot be loaded and parsed correctly, the // client SHOULD continue to use the previous values and attempt to reload // it after waiting for the previously-specified TTL (or 5 minutes if // none). this.logger_(`manifest failed to load ${error}.`); - // TODO: we may want to expose the error object here. - this.trigger('error'); + this.startTTLTimeout_(); return; } const steeringManifestJson = JSON.parse(this.request_.responseText); + this.startTTLTimeout_(); this.assignSteeringProperties_(steeringManifestJson); }); } @@ -200,6 +237,7 @@ export default class ContentSteeringController extends videojs.EventTarget { setSteeringParams_(url) { const urlObject = new window.URL(url); const path = this.getPathway(); + const networkThroughput = this.getBandwidth_(); if (path) { const pathwayKey = `_${this.manifestType_}_pathway`; @@ -207,11 +245,10 @@ export default class ContentSteeringController extends videojs.EventTarget { urlObject.searchParams.set(pathwayKey, path); } - if (this.mainSegmentLoader_.throughput.rate) { + if (networkThroughput) { const throughputKey = `_${this.manifestType_}_throughput`; - const rateInteger = Math.round(this.mainSegmentLoader_.throughput.rate); - urlObject.searchParams.set(throughputKey, rateInteger); + urlObject.searchParams.set(throughputKey, networkThroughput); } return urlObject.toString(); } @@ -234,44 +271,91 @@ export default class ContentSteeringController extends videojs.EventTarget { this.steeringManifest.priority = steeringJson['PATHWAY-PRIORITY'] || steeringJson['SERVICE-LOCATION-PRIORITY']; // TODO: HLS handle PATHWAY-CLONES. See section 7.2 https://datatracker.ietf.org/doc/draft-pantos-hls-rfc8216bis/ - // TODO: fully implement priority logic. // 1. apply first pathway from the array. - // 2. if first first pathway doesn't exist in manifest, try next pathway. + // 2. if first pathway doesn't exist in manifest, try next pathway. // a. if all pathways are exhausted, ignore the steering manifest priority. // 3. if segments fail from an established pathway, try all variants/renditions, then exclude the failed pathway. // a. exclude a pathway for a minimum of the last TTL duration. Meaning, from the next steering response, // the excluded pathway will be ignored. - const chooseNextPathway = (pathways) => { - for (const path of pathways) { + // See excludePathway usage in excludePlaylist(). + + // If there are no available pathways, we need to stop content steering. + if (!this.availablePathways_.size) { + this.logger_('There are no available pathways for content steering. Ending content steering.'); + this.trigger('error'); + this.dispose(); + } + + const chooseNextPathway = (pathwaysByPriority) => { + for (const path of pathwaysByPriority) { if (this.availablePathways_.has(path)) { return path; } } + + // If no pathway matches, ignore the manifest and choose the first available. + return [...this.availablePathways_][0]; }; + const nextPathway = chooseNextPathway(this.steeringManifest.priority); if (this.currentPathway !== nextPathway) { this.currentPathway = nextPathway; this.trigger('content-steering'); } - this.startTTLTimeout_(); } /** * Returns the pathway to use for steering decisions * - * @return returns the current pathway or the default + * @return {string} returns the current pathway or the default */ getPathway() { return this.currentPathway || this.defaultPathway; } + /** + * Chooses the manifest request URI based on proxy URIs and server URLs. + * Also accounts for exclusion on certain manifest URIs. + * + * @param {string} reloadUri the base uri before parameters + * + * @return {string} the final URI for the request to the manifest server. + */ + getRequestURI(reloadUri) { + if (!reloadUri) { + return null; + } + + const isExcluded = (uri) => this.excludedSteeringManifestURLs.has(uri); + + if (this.proxyServerUrl_) { + const proxyURI = this.setProxyServerUrl_(reloadUri); + + if (!isExcluded(proxyURI)) { + return proxyURI; + } + } + + const steeringURI = this.setSteeringParams_(reloadUri); + + if (!isExcluded(steeringURI)) { + return steeringURI; + } + + // Return nothing if all valid manifest URIs are excluded. + return null; + } + /** * Start the timeout for re-requesting the steering manifest at the TTL interval. + * + * @param {number} ttl time in seconds of the timeout. Defaults to the + * ttl interval in the steering manifest */ - startTTLTimeout_() { + startTTLTimeout_(ttl = this.steeringManifest.ttl) { // 300 (5 minutes) is the default value. - const ttlMS = this.steeringManifest.ttl * 1000; + const ttlMS = ttl * 1000; this.ttlTimeout_ = window.setTimeout(() => { this.requestSteeringManifest(); @@ -300,6 +384,8 @@ export default class ContentSteeringController extends videojs.EventTarget { * aborts steering requests clears the ttl timeout and resets all properties. */ dispose() { + this.off('content-steering'); + this.off('error'); this.abort(); this.clearTTLTimeout_(); this.currentPathway = null; @@ -309,6 +395,7 @@ export default class ContentSteeringController extends videojs.EventTarget { this.manifestType_ = null; this.ttlTimeout_ = null; this.request_ = null; + this.excludedSteeringManifestURLs = new Set(); this.availablePathways_ = new Set(); this.excludedPathways_ = new Set(); this.steeringManifest = new SteeringManifest(); @@ -320,6 +407,19 @@ export default class ContentSteeringController extends videojs.EventTarget { * @param {string} pathway the pathway string to add */ addAvailablePathway(pathway) { - this.availablePathways_.add(pathway); + if (pathway) { + this.availablePathways_.add(pathway); + } + } + + /** + * clears all pathways from the available pathways set + */ + clearAvailablePathways() { + this.availablePathways_.clear(); + } + + excludePathway(pathway) { + return this.availablePathways_.delete(pathway); } } diff --git a/src/dash-playlist-loader.js b/src/dash-playlist-loader.js index bb6523cc7..9a57fc781 100644 --- a/src/dash-playlist-loader.js +++ b/src/dash-playlist-loader.js @@ -325,7 +325,10 @@ export default class DashPlaylistLoader extends EventTarget { // live playlist staleness timeout this.on('mediaupdatetimeout', () => { - this.refreshMedia_(this.media().id); + // We handle live content steering in the playlist controller + if (!this.media().attributes.serviceLocation) { + this.refreshMedia_(this.media().id); + } }); this.state = 'HAVE_NOTHING'; diff --git a/src/media-groups.js b/src/media-groups.js index 66b7011a2..b6861e9e3 100644 --- a/src/media-groups.js +++ b/src/media-groups.js @@ -250,13 +250,10 @@ export const onError = { */ AUDIO: (type, settings) => () => { const { - segmentLoaders: { [type]: segmentLoader}, mediaTypes: { [type]: mediaType }, excludePlaylist } = settings; - stopLoaders(segmentLoader, mediaType); - // switch back to default audio track const activeTrack = mediaType.activeTrack(); const activeGroup = mediaType.activeGroup(); @@ -295,15 +292,12 @@ export const onError = { */ SUBTITLES: (type, settings) => () => { const { - segmentLoaders: { [type]: segmentLoader}, mediaTypes: { [type]: mediaType } } = settings; videojs.log.warn('Problem encountered loading the subtitle track.' + 'Disabling subtitle track.'); - stopLoaders(segmentLoader, mediaType); - const track = mediaType.activeTrack(); if (track) { diff --git a/src/playlist-controller.js b/src/playlist-controller.js index 1767409c3..2054f3705 100644 --- a/src/playlist-controller.js +++ b/src/playlist-controller.js @@ -27,6 +27,7 @@ import { createMediaTypes, setupMediaGroups } from './media-groups'; import logger from './util/logger'; import {merge, createTimeRanges} from './util/vjs-compat'; import { addMetadata, createMetadataTrackIfNotExists, addDateRangeMetadata } from './util/text-tracks'; +import ContentSteeringController from './content-steering-controller'; const ABORT_EARLY_EXCLUSION_SECONDS = 10; @@ -305,6 +306,11 @@ export class PlaylistController extends videojs.EventTarget { }) }), options); + const getBandwidth = () => { + return this.mainSegmentLoader_.bandwidth; + }; + + this.contentSteeringController_ = new ContentSteeringController(this.vhs_.xhr, getBandwidth); this.setupSegmentLoaderListeners_(); if (this.bufferBasedABR) { @@ -406,6 +412,35 @@ export class PlaylistController extends videojs.EventTarget { this.mainPlaylistLoader_.media(playlist, delay); } + /** + * A function that ensures we switch our playlists inside of `mediaTypes` + * to match the current `serviceLocation` provided by the contentSteering controller. + * We want to check media types of `AUDIO`, `SUBTITLES`, and `CLOSED-CAPTIONS`. + * + * This should only be called on a DASH playback scenario while using content steering. + * This is necessary due to differences in how media in HLS manifests are generally tied to + * a video playlist, where in DASH that is not always the case. + */ + switchMediaForDASHContentSteering_() { + ['AUDIO', 'SUBTITLES', 'CLOSED-CAPTIONS'].forEach((type) => { + const mediaType = this.mediaTypes_[type]; + const activeGroup = mediaType ? mediaType.activeGroup() : null; + const pathway = this.contentSteeringController_.getPathway(); + + if (activeGroup && pathway) { + // activeGroup can be an array or a single group + const mediaPlaylists = activeGroup.length ? activeGroup[0].playlists : activeGroup.playlists; + + const dashMediaPlaylists = mediaPlaylists.filter((p) => p.attributes.serviceLocation === pathway); + + // Switch the current active playlist to the correct CDN + if (dashMediaPlaylists.length) { + this.mediaTypes_[type].activePlaylistLoader.media(dashMediaPlaylists[0]); + } + } + }); + } + /** * Start a timer that periodically calls checkABR_ * @@ -572,6 +607,7 @@ export class PlaylistController extends videojs.EventTarget { let updatedPlaylist = this.mainPlaylistLoader_.media(); if (!updatedPlaylist) { + this.initContentSteeringController_(); // exclude any variants that are not supported by the browser before selecting // an initial media as the playlist selectors do not consider browser support this.excludeUnsupportedVariants_(); @@ -1222,6 +1258,19 @@ export class PlaylistController extends videojs.EventTarget { } if (isFinalRendition) { + // If we're content steering, try other pathways. + if (this.main().contentSteering) { + const pathway = this.pathwayAttribute_(playlistToExclude); + // Ignore at least 1 steering manifest refresh. + const reIncludeDelay = this.contentSteeringController_.steeringManifest.ttl * 1000; + + this.contentSteeringController_.excludePathway(pathway); + this.excludeThenChangePathway_(); + setTimeout(() => { + this.contentSteeringController_.addAvailablePathway(pathway); + }, reIncludeDelay); + return; + } // Since we're on the final non-excluded playlist, and we're about to exclude // it, instead of erring the player or retrying this playlist, clear out the current // exclusion list. This allows other playlists to be attempted in case any have been @@ -1640,6 +1689,7 @@ export class PlaylistController extends videojs.EventTarget { this.decrypter_.terminate(); this.mainPlaylistLoader_.dispose(); this.mainSegmentLoader_.dispose(); + this.contentSteeringController_.dispose(); if (this.loadOnPlay_) { this.tech_.off('play', this.loadOnPlay_); @@ -2052,4 +2102,128 @@ export class PlaylistController extends videojs.EventTarget { videoDuration }); } + + pathwayAttribute_(playlist) { + return playlist.attributes['PATHWAY-ID'] || playlist.attributes.serviceLocation; + } + /** + * Initialize content steering listeners and apply the tag properties. + */ + initContentSteeringController_() { + const initialMain = this.main(); + + if (!initialMain.contentSteering) { + return; + } + + const updateSteeringValues = (main) => { + for (const playlist of main.playlists) { + this.contentSteeringController_.addAvailablePathway(this.pathwayAttribute_(playlist)); + } + + this.contentSteeringController_.assignTagProperties(main.uri, main.contentSteering); + }; + + updateSteeringValues(initialMain); + + this.contentSteeringController_.on('content-steering', this.excludeThenChangePathway_.bind(this)); + + // We need to ensure we update the content steering values when a new + // manifest is loaded in live DASH with content steering. + if (this.sourceType_ === 'dash') { + this.mainPlaylistLoader_.on('mediaupdatetimeout', () => { + this.mainPlaylistLoader_.refreshMedia_(this.mainPlaylistLoader_.media().id); + + // clear past values + this.contentSteeringController_.abort(); + this.contentSteeringController_.clearTTLTimeout_(); + this.contentSteeringController_.clearAvailablePathways(); + + updateSteeringValues(this.main()); + }); + } + + // Do this at startup only, after that the steering requests are managed by the Content Steering class. + // DASH queryBeforeStart scenarios will be handled by the Content Steering class. + if (!this.contentSteeringController_.queryBeforeStart) { + this.tech_.one('canplay', () => { + this.contentSteeringController_.requestSteeringManifest(); + }); + } + } + + /** + * Simple exclude and change playlist logic for content steering. + */ + excludeThenChangePathway_() { + const currentPathway = this.contentSteeringController_.getPathway(); + + if (!currentPathway) { + return; + } + const main = this.main(); + const playlists = main.playlists; + const ids = new Set(); + let didEnablePlaylists = false; + + Object.keys(playlists).forEach((key) => { + const variant = playlists[key]; + const pathwayId = this.pathwayAttribute_(variant); + const differentPathwayId = pathwayId && currentPathway !== pathwayId; + const steeringExclusion = variant.excludeUntil === Infinity && variant.lastExcludeReason_ === 'content-steering'; + + if (steeringExclusion && !differentPathwayId) { + delete variant.excludeUntil; + delete variant.lastExcludeReason_; + didEnablePlaylists = true; + } + const noExcludeUntil = !variant.excludeUntil && variant.excludeUntil !== Infinity; + const shouldExclude = !ids.has(variant.id) && differentPathwayId && noExcludeUntil; + + if (!shouldExclude) { + return; + } + ids.add(variant.id); + variant.excludeUntil = Infinity; + variant.lastExcludeReason_ = 'content-steering'; + // TODO: kind of spammy, maybe move this. + this.logger_(`excluding ${variant.id} for ${variant.lastExcludeReason_}`); + }); + + if (this.contentSteeringController_.manifestType_ === 'DASH') { + Object.keys(this.mediaTypes_).forEach((key) => { + const type = this.mediaTypes_[key]; + + if (type.activePlaylistLoader) { + const currentPlaylist = type.activePlaylistLoader.media_; + + // Check if the current media playlist matches the current CDN + if (currentPlaylist && currentPlaylist.attributes.serviceLocation !== currentPathway) { + didEnablePlaylists = true; + } + } + }); + } + + if (didEnablePlaylists) { + this.changeSegmentPathway_(); + } + } + + /** + * Changes the current playlists for audio, video and subtitles after a new pathway + * is chosen from content steering. + */ + changeSegmentPathway_() { + const nextPlaylist = this.selectPlaylist(); + + this.pauseLoading(); + + // Switch audio and text track playlists if necessary in DASH + if (this.contentSteeringController_.manifestType_ === 'DASH') { + this.switchMediaForDASHContentSteering_(); + } + + this.switchMedia_(nextPlaylist, 'content-steering'); + } } diff --git a/test/content-steering-controller.test.js b/test/content-steering-controller.test.js index e9db50f34..033c0fc7b 100644 --- a/test/content-steering-controller.test.js +++ b/test/content-steering-controller.test.js @@ -2,22 +2,15 @@ import QUnit from 'qunit'; import ContentSteeringController from '../src/content-steering-controller'; import { useFakeEnvironment } from './test-helpers'; import xhrFactory from '../src/xhr'; +import sinon from 'sinon'; QUnit.module('ContentSteering', { beforeEach(assert) { this.env = useFakeEnvironment(assert); this.requests = this.env.requests; - this.fakeVhs = { - xhr: xhrFactory() - }; - this.mockSegmentLoader = { - vhs_: this.fakeVhs, - throughput: { - rate: 0 - } - }; this.baseURL = 'https://foo.bar'; - this.contentSteeringController = new ContentSteeringController(this.mockSegmentLoader); + this.contentSteeringController = new ContentSteeringController(xhrFactory(), () => undefined); + this.contentSteeringController.addAvailablePathway('test-1'); // handles a common testing flow of assigning tag properties and requesting the steering manifest immediately. this.assignAndRequest = (steeringTag) => { this.contentSteeringController.assignTagProperties(this.baseURL, steeringTag); @@ -42,6 +35,17 @@ QUnit.test('Can handle HLS content steering object with serverUri only', functio assert.equal(reloadUri, steeringTag.serverUri, 'reloadUri is expected value'); }); +QUnit.test('Fails when there is no serverUri', function(assert) { + const steeringTag = {}; + + this.assignAndRequest(steeringTag); + const reloadUri = this.contentSteeringController.steeringManifest.reloadUri; + const request = this.contentSteeringController.request_; + + assert.equal(reloadUri, null, 'reloadUri is null'); + assert.equal(request, null, 'no request is made'); +}); + QUnit.test('Can handle HLS content steering object and manifest with relative serverUri', function(assert) { const steeringTag = { serverUri: '/hls/path' @@ -84,8 +88,10 @@ QUnit.test('Can add HLS pathway and throughput to steering manifest requests', f }; const expectedThroughputUrl = steeringTag.serverUri + '/?_HLS_pathway=cdn-a&_HLS_throughput=99999'; + this.contentSteeringController.getBandwidth_ = () => { + return 99999; + }; this.contentSteeringController.assignTagProperties(this.baseURL, steeringTag); - this.mockSegmentLoader.throughput.rate = 99999; assert.equal(this.contentSteeringController.setSteeringParams_(steeringTag.serverUri), expectedThroughputUrl, 'pathway and throughput parameters set as expected'); }); @@ -156,8 +162,10 @@ QUnit.test('Can add DASH pathway and throughput to steering manifest requests', }; const expectedThroughputUrl = steeringTag.serverURL + '&_DASH_pathway=cdn-c&_DASH_throughput=9999'; + this.contentSteeringController.getBandwidth_ = () => { + return 9999; + }; this.contentSteeringController.assignTagProperties(this.baseURL, steeringTag); - this.mockSegmentLoader.throughput.rate = 9999; assert.equal(this.contentSteeringController.setSteeringParams_(steeringTag.serverURL), expectedThroughputUrl, 'pathway and throughput parameters set as expected'); }); @@ -179,7 +187,9 @@ QUnit.test('Can handle DASH proxyServerURL', function(assert) { }; const expectedProxyUrl = 'https://proxy.url/?url=https%3A%2F%2Fcontent.steering.dash%2F%3Fprevious%3Dparams&_DASH_pathway=dash-cdn&_DASH_throughput=99'; - this.mockSegmentLoader.throughput.rate = 99; + this.contentSteeringController.getBandwidth_ = () => { + return 99; + }; this.assignAndRequest(steeringTag); assert.equal(this.requests[0].uri, expectedProxyUrl, 'returns expected proxy server URL'); }); @@ -323,18 +333,177 @@ QUnit.test('trigger error when serverUri or serverURL is undefined', function(as this.contentSteeringController.assignTagProperties(this.baseURL, steeringTag); }); -QUnit.test('trigger error on steering manifest request error', function(assert) { +QUnit.test('trigger retry on steering manifest request error', function(assert) { const steeringTag = { serverUri: '/content/steering' }; - const manifest = this.contentSteeringController.steeringManifest; - const done = assert.async(); + const startTTLSpy = sinon.spy(this.contentSteeringController, 'startTTLTimeout_'); - this.contentSteeringController.on('error', function() { - assert.equal(manifest.version, undefined, 'version is undefined'); - assert.equal(manifest.ttl, undefined, 'ttl is undefined'); - done(); - }); + this.contentSteeringController.steeringManifest.ttl = 1; this.assignAndRequest(steeringTag); this.requests[0].respond(404); + assert.equal(startTTLSpy.callCount, 1, 'startTTLTimeout called on retry'); +}); + +QUnit.test('Disposes content steering when there is not a valid requestUri', function(assert) { + const steeringTag = { + serverUri: '/content/steering' + }; + + // There is no valid request URI + const getRequestURIStub = sinon.stub(this.contentSteeringController, 'getRequestURI'); + + getRequestURIStub.returns(null); + + const disposeSpy = sinon.spy(this.contentSteeringController, 'dispose'); + + this.assignAndRequest(steeringTag); + + const request = this.contentSteeringController.request_; + + assert.equal(request, null, 'no request is made'); + assert.ok(disposeSpy.called, 'diposes the content steering controller'); +}); + +QUnit.test('Exclude request URI on a 410 error', function(assert) { + const steeringTag = { + serverUri: '/content/steering' + }; + + const resolvedUri = this.baseURL + steeringTag.serverUri; + + this.assignAndRequest(steeringTag); + this.requests[0].respond(410); + + const excludedUri = [...this.contentSteeringController.excludedSteeringManifestURLs][0]; + + assert.equal(excludedUri, resolvedUri, 'exludes uri from future requests'); +}); + +QUnit.test('Set a retry on a 429 error', function(assert) { + const steeringTag = { + serverUri: '/content/steering' + }; + const startTTLSpy = sinon.spy(this.contentSteeringController, 'startTTLTimeout_'); + + this.assignAndRequest(steeringTag); + this.requests[0].respond(429, { 'Retry-After': 10 }); + + assert.deepEqual(startTTLSpy.getCall(0).args[0], 10, 'will retry the request in 10 seconds'); +}); + +// getRequestURI + +QUnit.test('getRequestURI returns null when no uri is passed', function(assert) { + const actual = this.contentSteeringController.getRequestURI(null); + + assert.deepEqual(actual, null, 'function should return null'); +}); + +QUnit.test('getRequestURI returns resolved proxyServerUrl when set', function(assert) { + const reloadUri = 'https://baseUrl.com'; + + this.contentSteeringController.proxyServerUrl_ = 'https://proxy.url'; + const actual = this.contentSteeringController.getRequestURI(reloadUri); + const expected = 'https://proxy.url/?url=https%3A%2F%2Fbaseurl.com%2F'; + + assert.deepEqual(actual, expected, 'function should return the resolved proxy url'); +}); + +QUnit.test('getRequestURI returns resolved reloadUri when set', function(assert) { + const reloadUri = 'https://baseUrl.com'; + + const actual = this.contentSteeringController.getRequestURI(reloadUri); + const expected = 'https://baseurl.com/'; + + assert.deepEqual(actual, expected, 'function should return the original reloadUri'); +}); + +QUnit.test('getRequestURI returns resolved reloadUri when proxyUri is excluded', function(assert) { + const reloadUri = 'https://baseUrl.com'; + + this.contentSteeringController.proxyServerUrl_ = 'https://proxy.url'; + // exlude the resolved url + this.contentSteeringController.excludedSteeringManifestURLs.add('https://proxy.url/?url=https%3A%2F%2Fbaseurl.com%2F'); + + const actual = this.contentSteeringController.getRequestURI(reloadUri); + const expected = 'https://baseurl.com/'; + + assert.deepEqual(actual, expected, 'function should return the original reloadUri'); +}); + +QUnit.test('getRequestURI returns null when both reloadUri or proxyUri are excluded', function(assert) { + const reloadUri = 'https://baseUrl.com'; + + this.contentSteeringController.proxyServerUrl_ = 'https://proxy.url'; + // exlude the resolved urls + this.contentSteeringController.excludedSteeringManifestURLs.add('https://proxy.url/?url=https%3A%2F%2Fbaseurl.com%2F'); + this.contentSteeringController.excludedSteeringManifestURLs.add('https://baseurl.com/'); + + const actual = this.contentSteeringController.getRequestURI(reloadUri); + + assert.deepEqual(actual, null, 'function should return null'); +}); + +// Switching Logic + +QUnit.test('chooses pathway with highest priority', function(assert) { + const steeringTag = { + serverUri: '/content/steering' + }; + + this.contentSteeringController.addAvailablePathway('cdn-a'); + this.contentSteeringController.addAvailablePathway('cdn-b'); + this.contentSteeringController.addAvailablePathway('cdn-c'); + + this.assignAndRequest(steeringTag); + this.requests[0].respond(200, { 'Content-Type': 'application/json' }, '{ "VERSION": 1, "PATHWAY-PRIORITY": ["cdn-b", "cdn-a", "cdn-c"] }'); + + const expected = this.contentSteeringController.currentPathway; + + assert.equal(expected, 'cdn-b', 'pathway with highest priority is selected'); +}); + +QUnit.test('chooses pathway with highest priority when pathway is not available', function(assert) { + const steeringTag = { + serverUri: '/content/steering' + }; + + this.contentSteeringController.addAvailablePathway('cdn-a'); + this.contentSteeringController.addAvailablePathway('cdn-c'); + + this.assignAndRequest(steeringTag); + this.requests[0].respond(200, { 'Content-Type': 'application/json' }, '{ "VERSION": 1, "PATHWAY-PRIORITY": ["cdn-b", "cdn-c", "cdn-a"] }'); + + const expected = this.contentSteeringController.currentPathway; + + assert.equal(expected, 'cdn-c', 'pathway with highest priority that is available'); +}); + +QUnit.test('chooses first pathway when none are in the priority list', function(assert) { + const steeringTag = { + serverUri: '/content/steering' + }; + + this.assignAndRequest(steeringTag); + this.requests[0].respond(200, { 'Content-Type': 'application/json' }, '{ "VERSION": 1, "PATHWAY-PRIORITY": ["cdn-z"] }'); + + const expected = this.contentSteeringController.currentPathway; + + // test-1 is the default pathway added in beforeEach + assert.equal(expected, 'test-1', 'use first pathway when none exist on the priority list'); +}); + +QUnit.test('disposes the controller when there are no available pathways', function(assert) { + const steeringTag = { + serverUri: '/content/steering' + }; + + this.contentSteeringController.excludePathway('test-1'); + const disposeSpy = sinon.spy(this.contentSteeringController, 'dispose'); + + this.assignAndRequest(steeringTag); + this.requests[0].respond(200, { 'Content-Type': 'application/json' }, '{ "VERSION": 1, "PATHWAY-PRIORITY": ["cdn-z"] }'); + + assert.ok(disposeSpy.called, 'diposes the content steering controller'); }); diff --git a/test/dash-playlist-loader.test.js b/test/dash-playlist-loader.test.js index 6285e1b14..9c56b3c4d 100644 --- a/test/dash-playlist-loader.test.js +++ b/test/dash-playlist-loader.test.js @@ -2821,6 +2821,62 @@ QUnit.test('pause does not remove minimum update period timeout when not main', ); }); +QUnit.test('Content Steering with Live DASH should NOT update media', function(assert) { + const mainLoader = new DashPlaylistLoader('dash-live.mpd', this.fakeVhs); + + const refreshMediaSpy = sinon.stub(mainLoader, 'refreshMedia_'); + + mainLoader.load(); + this.standardXHRResponse(this.requests.shift()); + this.clock.tick(1); + + const media = mainLoader.main.playlists[0]; + + mainLoader.media = () => media; + + // This means content steering is active on the media. + media.attributes.serviceLocation = 'cdn-a'; + + mainLoader.media(media); + + // This means there was a DASH live update. + mainLoader.trigger('mediaupdatetimeout'); + + // If refreshMedia_ is only called once, it means it was called on initialization, + // and is expected to be called later by the playlist controller. + assert.equal( + refreshMediaSpy.callCount, + 1 + ); +}); + +QUnit.test('Live DASH without content steering should update media', function(assert) { + const mainLoader = new DashPlaylistLoader('dash-live.mpd', this.fakeVhs); + + const refreshMediaSpy = sinon.stub(mainLoader, 'refreshMedia_'); + + mainLoader.load(); + this.standardXHRResponse(this.requests.shift()); + this.clock.tick(1); + + const media = mainLoader.main.playlists[0]; + + mainLoader.media = () => media; + + mainLoader.media(media); + + // This means there was a DASH live update. + mainLoader.trigger('mediaupdatetimeout'); + + // If refreshMedia_ is called twice, it means it is was called on initialization, + // and later when there is a live update. This should all be handled by the + // playlist controller. + assert.equal( + refreshMediaSpy.callCount, + 2 + ); +}); + QUnit.test('updateMain: merges in top level timelineStarts', function(assert) { const prev = { timelineStarts: [0, 1], diff --git a/test/playlist-controller.test.js b/test/playlist-controller.test.js index 3fb78b095..272f3abf4 100644 --- a/test/playlist-controller.test.js +++ b/test/playlist-controller.test.js @@ -6325,3 +6325,386 @@ QUnit.test('should delay loading of new playlist if lastRequest was less than ha this.env.log.warn.callCount = 0; }); +// Content Steering +QUnit.module('PlaylistController contentSteering', { + beforeEach(assert) { + sharedHooks.beforeEach.call(this, assert); + + this.controllerOptions = { + src: 'test', + tech: this.player.tech_, + sourceType: 'dash' + }; + + this.csMainPlaylist = { + contentSteering: { + defaultServiceLocation: 'cdn-a', + serverURL: 'https://www.server.test' + }, + playlists: [ + { + attributes: { + NAME: 'video_1920x1080_4531kbps', + serviceLocation: 'cdn-a' + }, + endList: true, + id: '0-placeholder-uri-0', + resolvedUri: 'https://fastly.content-steering.com/bbb/placeholder-uri-0', + uri: 'placeholder-uri-0' + }, + { + attributes: { + NAME: 'video_1280x720_2445kbps', + serviceLocation: 'cdn-b' + }, + endList: true, + id: '1-placeholder-uri-1', + resolvedUri: 'https://fastly.content-steering.com/bbb/placeholder-uri-1', + uri: 'placeholder-uri-1' + } + ] + }; + + }, + afterEach(assert) { + sharedHooks.afterEach.call(this, assert); + } +}); + +QUnit.test('initContentSteeringController_ for HLS', function(assert) { + const options = { + src: 'test', + tech: this.player.tech_, + sourceType: 'hls' + }; + + const pc = new PlaylistController(options); + + const mainPlaylist = { + contentSteering: { + ['PATHWAY-ID']: 'cdn-a', + serverUri: 'https://www.server.test/hls' + }, + playlists: [ + { + attributes: { + ['PATHWAY-ID']: 'cdn-a' + }, + endList: true, + id: '0-placeholder-uri-0', + resolvedUri: 'https://fastly.content-steering.com/bbb/placeholder-uri-0', + uri: 'placeholder-uri-0' + }, + { + attributes: { + ['PATHWAY-ID']: 'cdn-b' + }, + endList: true, + id: '1-placeholder-uri-1', + resolvedUri: 'https://fastly.content-steering.com/bbb/placeholder-uri-1', + uri: 'placeholder-uri-1' + } + ] + }; + + pc.main = () => mainPlaylist; + + pc.initContentSteeringController_(); + + const steering = pc.contentSteeringController_; + const pathways = [...steering.availablePathways_]; + + assert.deepEqual(pathways[0], 'cdn-a'); + assert.deepEqual(pathways[1], 'cdn-b'); + assert.deepEqual(steering.manifestType_, 'HLS'); + assert.deepEqual(steering.steeringManifest.reloadUri, mainPlaylist.contentSteering.serverUri); +}); + +QUnit.test('initContentSteeringController_ for DASH with queryBeforeStart', function(assert) { + const pc = new PlaylistController(this.controllerOptions); + const requestSteeringManifestSpy = sinon.spy(pc.contentSteeringController_, 'requestSteeringManifest'); + + const mainPlaylist = Object.assign({}, this.csMainPlaylist); + + mainPlaylist.contentSteering.queryBeforeStart = true; + + pc.main = () => mainPlaylist; + + pc.initContentSteeringController_(); + + // requestManifest is called, which means a request to the steering server is made. + assert.ok(requestSteeringManifestSpy.called); + + const steering = pc.contentSteeringController_; + const pathways = [...steering.availablePathways_]; + + assert.deepEqual(pathways[0], 'cdn-a'); + assert.deepEqual(pathways[1], 'cdn-b'); + assert.deepEqual(steering.manifestType_, 'DASH'); + assert.deepEqual(steering.steeringManifest.reloadUri, mainPlaylist.contentSteering.serverURL); +}); + +QUnit.test('initContentSteeringController_ for DASH without queryBeforeStart', function(assert) { + const pc = new PlaylistController(this.controllerOptions); + const requestSteeringManifestSpy = sinon.spy(pc.contentSteeringController_, 'requestSteeringManifest'); + + pc.main = () => this.csMainPlaylist; + + pc.initContentSteeringController_(); + + // requestManifest is NOT called yet without queryBeforeStart + assert.notOk(requestSteeringManifestSpy.called); + + // Now the playlist should make the request to the content steering server + // This event means the media should already be loaded. + this.player.tech_.trigger('canplay'); + + // requestManifest is called, which means a request to the steering server is made. + assert.ok(requestSteeringManifestSpy.called); + + const steering = pc.contentSteeringController_; + const pathways = [...steering.availablePathways_]; + + assert.deepEqual(pathways[0], 'cdn-a'); + assert.deepEqual(pathways[1], 'cdn-b'); + assert.deepEqual(steering.manifestType_, 'DASH'); + assert.deepEqual(steering.steeringManifest.reloadUri, this.csMainPlaylist.contentSteering.serverURL); +}); + +QUnit.test('Test Live DASH update with content steering', function(assert) { + const pc = new PlaylistController(this.controllerOptions); + + // Stub the steering request functionality and the resetting of media. + sinon.stub(pc.contentSteeringController_, 'requestSteeringManifest'); + sinon.stub(pc.mainPlaylistLoader_, 'refreshMedia_'); + + // Second manifest after live update just changes the queryBeforeStartParam + const mainPlaylistAfter = Object.assign({}, this.csMainPlaylist); + + pc.main = () => this.csMainPlaylist; + pc.mainPlaylistLoader_.media = () => this.csMainPlaylist.playlists[0]; + + pc.initContentSteeringController_(); + + // The initial manifest did not have queryBeforeStart set + assert.deepEqual(pc.contentSteeringController_.queryBeforeStart, false); + + // mimics refreshMedia_, resetting main with the new manifest + mainPlaylistAfter.contentSteering.queryBeforeStart = true; + pc.main = () => mainPlaylistAfter; + + // mimic a live DASH manifest update + pc.mainPlaylistLoader_.trigger('mediaupdatetimeout'); + + // The content steering controller was updated with the new information. + assert.deepEqual(pc.contentSteeringController_.queryBeforeStart, true); + + pc.dispose(); +}); + +QUnit.test('Exclude and reinclude pathway after timeout for content steering', function(assert) { + const pc = new PlaylistController(this.controllerOptions); + + const mainPlaylist = Object.assign({}, this.csMainPlaylist); + + // playlist for cdn-b is currently excluded + mainPlaylist.playlists[1].excludeUntil = Infinity; + mainPlaylist.playlists[1].lastExcludeReason_ = 'content-steering'; + + // Set up playlists + pc.main = () => mainPlaylist; + pc.media = () => mainPlaylist.playlists[0]; + pc.mainPlaylistLoader_.main = mainPlaylist; + pc.mainPlaylistLoader_.media = () => mainPlaylist.playlists[0]; + pc.selectPlaylist = () => pc.main().playlists[0]; + + pc.initContentSteeringController_(); + + // The content steering controller has the pathway available. + assert.ok(pc.contentSteeringController_.availablePathways_.has('cdn-a')); + + pc.excludePlaylist({ + playlistToExclude: pc.main().playlists[0], + error: { internal: true } + }); + + // The pathway was removed from the available pathways. + assert.notOk(pc.contentSteeringController_.availablePathways_.has('cdn-a')); + + // A timeout was set, to fast forward to when the pathway should be included again. + this.clock.tick(4); + + // The pathway was added back to the available pathways. + assert.ok(pc.contentSteeringController_.availablePathways_.has('cdn-a')); +}); + +QUnit.test('switch media on priority change for content steering', function(assert) { + const pc = new PlaylistController(this.controllerOptions); + + const mainPlaylist = Object.assign({}, this.csMainPlaylist); + + // playlist for cdn-b is currently excluded + mainPlaylist.playlists[1].excludeUntil = Infinity; + mainPlaylist.playlists[1].lastExcludeReason_ = 'content-steering'; + + // Set up playlists + pc.main = () => mainPlaylist; + pc.media = () => mainPlaylist.playlists[0]; + pc.selectPlaylist = () => pc.main().playlists[0]; + + const switchMediaStub = sinon.stub(pc, 'switchMedia_'); + + pc.initContentSteeringController_(); + + // Initially, cdn-a should be selected and there should be no media switch + assert.deepEqual(pc.contentSteeringController_.getPathway(), 'cdn-a'); + assert.notOk(switchMediaStub.called); + // The playlist for cdn-b is excluded + assert.deepEqual(pc.main().playlists[1].excludeUntil, Infinity); + + // selectPlaylist has to be mocked + pc.selectPlaylist = () => pc.main().playlists[1]; + + const steeringManifestJson = { + VERSION: 1, + TTL: 10, + ['RELOAD-URI']: 'https://fastly-server.content-steering.com/dash.dcsm', + ['PATHWAY-PRIORITY']: [ + 'cdn-b', + 'cdn-a' + ] + }; + + // mimic a response from the content server + pc.contentSteeringController_.assignSteeringProperties_(steeringManifestJson); + + // When the priority changes in the manifest, the media should switch to cdn-b + assert.deepEqual(switchMediaStub.getCall(0).args[0].attributes.serviceLocation, 'cdn-b'); + assert.deepEqual(pc.contentSteeringController_.getPathway(), 'cdn-b'); + // The playlist for cdn-b is no longer excluded + assert.deepEqual(pc.main().playlists[1].excludeUntil, undefined); +}); + +QUnit.test('media group playlists should switch on steering change', function(assert) { + const pc = new PlaylistController(this.controllerOptions); + + const mainPlaylist = Object.assign({}, this.csMainPlaylist); + + // playlist for cdn-b is currently excluded + mainPlaylist.playlists[1].excludeUntil = Infinity; + mainPlaylist.playlists[1].lastExcludeReason_ = 'content-steering'; + + mainPlaylist.mediaGroups = { + AUDIO: { + audio: { + und: { + language: 'und', + default: true, + autoselect: true, + playlists: [ + { + attributes: { + NAME: 'audio_128kbps', + CODECS: 'mp4a.40.2', + serviceLocation: 'cdn-a' + }, + endList: true, + id: '0-placeholder-uri-AUDIO-audio-audio_128kbps', + uri: 'placeholder-uri-AUDIO-audio-audio_128kbps', + resolvedUri: 'placeholder-uri-AUDIO-audio-audio_128kbps' + }, + { + attributes: { + NAME: 'audio_128kbps', + CODECS: 'mp4a.40.2', + serviceLocation: 'cdn-b' + }, + endList: true, + id: '1-placeholder-uri-AUDIO-audio-audio_128kbps', + uri: '1-placeholder-uri-AUDIO-audio-audio_128kbps', + resolvedUri: '1-placeholder-uri-AUDIO-audio-audio_128kbps' + } + ] + } + } + }, + ['CLOSED_CAPTIONS']: {}, + SUBTITLES: {}, + VIDEO: {} + }; + + // Set up playlists + pc.main = () => mainPlaylist; + pc.media = () => mainPlaylist.playlists[0]; + pc.mainPlaylistLoader_.main = mainPlaylist; + pc.mainPlaylistLoader_.media = () => mainPlaylist.playlists[0]; + pc.selectPlaylist = () => pc.main().playlists[0]; + + // Set up mediaTypes_ groups + pc.mediaTypes_.AUDIO.groups = [{ + audio: [mainPlaylist.mediaGroups.AUDIO.audio.und] + }]; + pc.mediaTypes_.AUDIO.activeGroup = () => [ + mainPlaylist.mediaGroups.AUDIO.audio.und + ]; + pc.mediaTypes_.AUDIO.activeTrack = () => ({label: 'und'}); + + const audioPlaylist = mainPlaylist.mediaGroups.AUDIO.audio.und.playlists[0]; + + pc.mediaTypes_.AUDIO.activePlaylistLoader = { + media: () => audioPlaylist, + media_: audioPlaylist + }; + + // Set up stubs + sinon.stub(pc, 'switchMedia_'); + const mediaSpy = sinon.spy(pc.mediaTypes_.AUDIO.activePlaylistLoader, 'media'); + + pc.initContentSteeringController_(); + + const steeringManifestJson = { + VERSION: 1, + TTL: 10, + ['RELOAD-URI']: 'https://fastly-server.content-steering.com/dash.dcsm', + ['PATHWAY-PRIORITY']: [ + 'cdn-b', + 'cdn-a' + ] + }; + + // mimic a response from the content server + pc.contentSteeringController_.assignSteeringProperties_(steeringManifestJson); + + // the audio media() is called with the playlist for cdn-b + assert.deepEqual(mediaSpy.getCall(0).args[0].attributes.serviceLocation, 'cdn-b'); +}); + +QUnit.test('playlists should not change when there is no currentPathway', function(assert) { + const pc = new PlaylistController(this.controllerOptions); + + const switchMediaSpy = sinon.spy(pc, 'switchMedia_'); + + // Set up playlists + pc.main = () => this.csMainPlaylist; + + pc.initContentSteeringController_(); + + // mimic there being no current pathway + pc.contentSteeringController_.getPathway = () => null; + + const steeringManifestJson = { + VERSION: 1, + TTL: 10, + ['RELOAD-URI']: 'https://fastly-server.content-steering.com/dash.dcsm', + ['PATHWAY-PRIORITY']: [ + 'cdn-b', + 'cdn-a' + ] + }; + + // mimic a response from the content server + pc.contentSteeringController_.assignSteeringProperties_(steeringManifestJson); + + // media is never switched + assert.notOk(switchMediaSpy.called); +});