UNPKG

@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
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_; } }