@videojs/http-streaming
Version:
Play back HLS and DASH with Video.js, even where it's not natively supported
490 lines (425 loc) • 16.6 kB
JavaScript
import resolveUrl from './resolve-url';
import window from 'global/window';
import logger from './util/logger';
import videojs from 'video.js';
/**
* A utility class for setting properties and maintaining the state of the content steering manifest.
*
* Content Steering manifest format:
* VERSION: number (required) currently only version 1 is supported.
* TTL: number in seconds (optional) until the next content steering manifest reload.
* RELOAD-URI: string (optional) uri to fetch the next content steering manifest.
* SERVICE-LOCATION-PRIORITY or PATHWAY-PRIORITY a non empty array of unique string values.
* PATHWAY-CLONES: array (optional) (HLS only) pathway clone objects to copy from other playlists.
*/
class SteeringManifest {
constructor() {
this.priority_ = [];
this.pathwayClones_ = new Map();
}
set version(number) {
// Only version 1 is currently supported for both DASH and HLS.
if (number === 1) {
this.version_ = number;
}
}
set ttl(seconds) {
// TTL = time-to-live, default = 300 seconds.
this.ttl_ = seconds || 300;
}
set reloadUri(uri) {
if (uri) {
// reload URI can be relative to the previous reloadUri.
this.reloadUri_ = resolveUrl(this.reloadUri_, uri);
}
}
set priority(array) {
// priority must be non-empty and unique values.
if (array && array.length) {
this.priority_ = array;
}
}
set pathwayClones(array) {
// pathwayClones must be non-empty.
if (array && array.length) {
this.pathwayClones_ = new Map(array.map((clone) => [clone.ID, clone]));
}
}
get version() {
return this.version_;
}
get ttl() {
return this.ttl_;
}
get reloadUri() {
return this.reloadUri_;
}
get priority() {
return this.priority_;
}
get pathwayClones() {
return this.pathwayClones_;
}
}
/**
* This class represents a content steering manifest and associated state. See both HLS and DASH specifications.
* HLS: https://developer.apple.com/streaming/HLSContentSteeringSpecification.pdf and
* 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 {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 {
constructor(xhr, bandwidth) {
super();
this.currentPathway = null;
this.defaultPathway = null;
this.queryBeforeStart = false;
this.availablePathways_ = new Set();
this.steeringManifest = new SteeringManifest();
this.proxyServerUrl_ = null;
this.manifestType_ = null;
this.ttlTimeout_ = null;
this.request_ = null;
this.currentPathwayClones = new Map();
this.nextPathwayClones = new Map();
this.excludedSteeringManifestURLs = new Set();
this.logger_ = logger('Content Steering');
this.xhr_ = xhr;
this.getBandwidth_ = bandwidth;
}
/**
* Assigns the content steering tag properties to the steering controller
*
* @param {string} baseUrl the baseURL from the main manifest for resolving the steering manifest url
* @param {Object} steeringTag the content steering tag from the main manifest
*/
assignTagProperties(baseUrl, steeringTag) {
this.manifestType_ = steeringTag.serverUri ? 'HLS' : 'DASH';
// serverUri is HLS serverURL is DASH
const steeringUri = steeringTag.serverUri || steeringTag.serverURL;
if (!steeringUri) {
this.logger_(`steering manifest URL is ${steeringUri}, cannot request steering manifest.`);
this.trigger('error');
return;
}
// Content steering manifests can be encoded as a data URI. We can decode, parse and return early if that's the case.
if (steeringUri.startsWith('data:')) {
this.decodeDataUriManifest_(steeringUri.substring(steeringUri.indexOf(',') + 1));
return;
}
// reloadUri is the resolution of the main manifest URL and steering URL.
this.steeringManifest.reloadUri = 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.
this.queryBeforeStart = steeringTag.queryBeforeStart;
this.proxyServerUrl_ = steeringTag.proxyServerURL;
// trigger a steering event if we have a pathway from the content steering tag.
// this tells VHS which segment pathway to start with.
// If queryBeforeStart is true we need to wait for the steering manifest response.
if (this.defaultPathway && !this.queryBeforeStart) {
this.trigger('content-steering');
}
}
/**
* 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 should only happen once on initalization.
*/
requestSteeringManifest(initial) {
const reloadUri = this.steeringManifest.reloadUri;
if (!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.
// This request URI accounts for manifest URIs that have been excluded.
const uri = initial ? reloadUri : 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;
}
const metadata = {
contentSteeringInfo: {
uri
}
};
this.trigger({ type: 'contentsteeringloadstart', metadata });
this.request_ = this.xhr_({
uri,
requestType: 'content-steering-manifest'
}, (error, errorInfo) => {
if (error) {
// 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}.`);
this.startTTLTimeout_();
return;
}
this.trigger({ type: 'contentsteeringloadcomplete', metadata });
let steeringManifestJson;
try {
steeringManifestJson = JSON.parse(this.request_.responseText);
} catch (parseError) {
const errorMetadata = {
errorType: videojs.Error.StreamingContentSteeringParserError,
error: parseError
};
this.trigger({ type: 'error', metadata: errorMetadata });
}
this.assignSteeringProperties_(steeringManifestJson);
const parsedMetadata = {
contentSteeringInfo: metadata.contentSteeringInfo,
contentSteeringManifest: {
version: this.steeringManifest.version,
reloadUri: this.steeringManifest.reloadUri,
priority: this.steeringManifest.priority
}
};
this.trigger({ type: 'contentsteeringparsed', metadata: parsedMetadata });
this.startTTLTimeout_();
});
}
/**
* Set the proxy server URL and add the steering manifest url as a URI encoded parameter.
*
* @param {string} steeringUrl the steering manifest url
* @return the steering manifest url to a proxy server with all parameters set
*/
setProxyServerUrl_(steeringUrl) {
const steeringUrlObject = new window.URL(steeringUrl);
const proxyServerUrlObject = new window.URL(this.proxyServerUrl_);
proxyServerUrlObject.searchParams.set('url', encodeURI(steeringUrlObject.toString()));
return this.setSteeringParams_(proxyServerUrlObject.toString());
}
/**
* Decodes and parses the data uri encoded steering manifest
*
* @param {string} dataUri the data uri to be decoded and parsed.
*/
decodeDataUriManifest_(dataUri) {
const steeringManifestJson = JSON.parse(window.atob(dataUri));
this.assignSteeringProperties_(steeringManifestJson);
}
/**
* Set the HLS or DASH content steering manifest request query parameters. For example:
* _HLS_pathway="<CURRENT-PATHWAY-ID>" and _HLS_throughput=<THROUGHPUT>
* _DASH_pathway and _DASH_throughput
*
* @param {string} uri to add content steering server parameters to.
* @return a new uri as a string with the added steering query parameters.
*/
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 (networkThroughput) {
const throughputKey = `_${this.manifestType_}_throughput`;
urlObject.searchParams.set(throughputKey, networkThroughput);
}
return urlObject.toString();
}
/**
* Assigns the current steering manifest properties and to the SteeringManifest object
*
* @param {Object} steeringJson the raw JSON steering manifest
*/
assignSteeringProperties_(steeringJson) {
this.steeringManifest.version = steeringJson.VERSION;
if (!this.steeringManifest.version) {
this.logger_(`manifest version is ${steeringJson.VERSION}, which is not supported.`);
this.trigger('error');
return;
}
this.steeringManifest.ttl = steeringJson.TTL;
this.steeringManifest.reloadUri = steeringJson['RELOAD-URI'];
// HLS = PATHWAY-PRIORITY required. DASH = SERVICE-LOCATION-PRIORITY optional
this.steeringManifest.priority = steeringJson['PATHWAY-PRIORITY'] || steeringJson['SERVICE-LOCATION-PRIORITY'];
// Pathway clones to be created/updated in HLS.
// See section 7.2 https://datatracker.ietf.org/doc/draft-pantos-hls-rfc8216bis/
this.steeringManifest.pathwayClones = steeringJson['PATHWAY-CLONES'];
this.nextPathwayClones = this.steeringManifest.pathwayClones;
// 1. apply first pathway from the array.
// 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.
// 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');
}
}
/**
* Returns the pathway to use for steering decisions
*
* @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_(ttl = this.steeringManifest.ttl) {
// 300 (5 minutes) is the default value.
const ttlMS = ttl * 1000;
this.ttlTimeout_ = window.setTimeout(() => {
this.requestSteeringManifest();
}, ttlMS);
}
/**
* Clear the TTL timeout if necessary.
*/
clearTTLTimeout_() {
window.clearTimeout(this.ttlTimeout_);
this.ttlTimeout_ = null;
}
/**
* aborts any current steering xhr and sets the current request object to null
*/
abort() {
if (this.request_) {
this.request_.abort();
}
this.request_ = null;
}
/**
* 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;
this.defaultPathway = null;
this.queryBeforeStart = null;
this.proxyServerUrl_ = null;
this.manifestType_ = null;
this.ttlTimeout_ = null;
this.request_ = null;
this.excludedSteeringManifestURLs = new Set();
this.availablePathways_ = new Set();
this.steeringManifest = new SteeringManifest();
}
/**
* adds a pathway to the available pathways set
*
* @param {string} pathway the pathway string to add
*/
addAvailablePathway(pathway) {
if (pathway) {
this.availablePathways_.add(pathway);
}
}
/**
* Clears all pathways from the available pathways set
*/
clearAvailablePathways() {
this.availablePathways_.clear();
}
/**
* Removes a pathway from the available pathways set.
*/
excludePathway(pathway) {
return this.availablePathways_.delete(pathway);
}
/**
* Checks the refreshed DASH manifest content steering tag for changes.
*
* @param {string} baseURL new steering tag on DASH manifest refresh
* @param {Object} newTag the new tag to check for changes
* @return a true or false whether the new tag has different values
*/
didDASHTagChange(baseURL, newTag) {
return !newTag && this.steeringManifest.reloadUri ||
newTag && (resolveUrl(baseURL, newTag.serverURL) !== this.steeringManifest.reloadUri ||
newTag.defaultServiceLocation !== this.defaultPathway ||
newTag.queryBeforeStart !== this.queryBeforeStart ||
newTag.proxyServerURL !== this.proxyServerUrl_);
}
getAvailablePathways() {
return this.availablePathways_;
}
}