Skip to content

Commit

Permalink
feat: content steering switching (#1427)
Browse files Browse the repository at this point in the history
  • Loading branch information
wseymour15 authored Sep 26, 2023
1 parent c94c8dd commit dd5e2af
Show file tree
Hide file tree
Showing 7 changed files with 946 additions and 67 deletions.
178 changes: 139 additions & 39 deletions src/content-steering-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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 <ContentSteering> 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);
});
}
Expand Down Expand Up @@ -200,18 +237,18 @@ 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`;

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();
}
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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);
}
}
5 changes: 4 additions & 1 deletion src/dash-playlist-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 0 additions & 6 deletions src/media-groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit dd5e2af

Please sign in to comment.