Skip to content

Commit dd5e2af

Browse files
authored
feat: content steering switching (#1427)
1 parent c94c8dd commit dd5e2af

7 files changed

+946
-67
lines changed

src/content-steering-controller.js

Lines changed: 139 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -66,26 +66,27 @@ class SteeringManifest {
6666
* https://datatracker.ietf.org/doc/draft-pantos-hls-rfc8216bis/ section 4.4.6.6.
6767
* DASH: https://dashif.org/docs/DASH-IF-CTS-00XX-Content-Steering-Community-Review.pdf
6868
*
69-
* @param {Object} segmentLoader a reference to the mainSegmentLoader
69+
* @param {function} xhr for making a network request from the browser.
70+
* @param {function} bandwidth for fetching the current bandwidth from the main segment loader.
7071
*/
7172
export default class ContentSteeringController extends videojs.EventTarget {
72-
// pass a segment loader reference for throughput rate and xhr
73-
constructor(segmentLoader) {
73+
constructor(xhr, bandwidth) {
7474
super();
7575

7676
this.currentPathway = null;
7777
this.defaultPathway = null;
7878
this.queryBeforeStart = null;
7979
this.availablePathways_ = new Set();
80-
// TODO: Implement exclusion.
8180
this.excludedPathways_ = new Set();
8281
this.steeringManifest = new SteeringManifest();
8382
this.proxyServerUrl_ = null;
8483
this.manifestType_ = null;
8584
this.ttlTimeout_ = null;
8685
this.request_ = null;
87-
this.mainSegmentLoader_ = segmentLoader;
86+
this.excludedSteeringManifestURLs = new Set();
8887
this.logger_ = logger('Content Steering');
88+
this.xhr_ = xhr;
89+
this.getBandwidth_ = bandwidth;
8990
}
9091

9192
/**
@@ -109,57 +110,93 @@ export default class ContentSteeringController extends videojs.EventTarget {
109110
this.decodeDataUriManifest_(steeringUri.substring(steeringUri.indexOf(',') + 1));
110111
return;
111112
}
112-
this.steeringManifest.reloadUri = resolveUrl(baseUrl, steeringUri);
113+
// With DASH queryBeforeStart, we want to use the steeringUri as soon as possible for the request.
114+
this.steeringManifest.reloadUri = this.queryBeforeStart ? steeringUri : resolveUrl(baseUrl, steeringUri);
113115
// pathwayId is HLS defaultServiceLocation is DASH
114116
this.defaultPathway = steeringTag.pathwayId || steeringTag.defaultServiceLocation;
115117
// currently only DASH supports the following properties on <ContentSteering> tags.
116-
if (this.manifestType_ === 'DASH') {
117-
this.queryBeforeStart = steeringTag.queryBeforeStart || false;
118-
this.proxyServerUrl_ = steeringTag.proxyServerURL;
119-
}
118+
this.queryBeforeStart = steeringTag.queryBeforeStart || false;
119+
this.proxyServerUrl_ = steeringTag.proxyServerURL || null;
120120

121121
// trigger a steering event if we have a pathway from the content steering tag.
122122
// this tells VHS which segment pathway to start with.
123-
if (this.defaultPathway) {
123+
// If queryBeforeStart is true we need to wait for the steering manifest response.
124+
if (this.defaultPathway && !this.queryBeforeStart) {
124125
this.trigger('content-steering');
125126
}
127+
128+
if (this.queryBeforeStart) {
129+
this.requestSteeringManifest(this.steeringManifest.reloadUri);
130+
}
126131
}
127132

128133
/**
129134
* Requests the content steering manifest and parse the response. This should only be called after
130135
* assignTagProperties was called with a content steering tag.
136+
*
137+
* @param {string} initialUri The optional uri to make the request with.
138+
* If set, the request should be made with exactly what is passed in this variable.
139+
* This scenario is specific to DASH when the queryBeforeStart parameter is true.
140+
* This scenario should only happen once on initalization.
131141
*/
132-
requestSteeringManifest() {
133-
// add parameters to the steering uri
142+
requestSteeringManifest(initialUri) {
134143
const reloadUri = this.steeringManifest.reloadUri;
144+
145+
if (!initialUri && !reloadUri) {
146+
return;
147+
}
148+
135149
// We currently don't support passing MPD query parameters directly to the content steering URL as this requires
136150
// ExtUrlQueryInfo tag support. See the DASH content steering spec section 8.1.
137-
const uri = this.proxyServerUrl_ ? this.setProxyServerUrl_(reloadUri) : this.setSteeringParams_(reloadUri);
138151

139-
this.request_ = this.mainSegmentLoader_.vhs_.xhr({
152+
// This request URI accounts for manifest URIs that have been excluded.
153+
const uri = initialUri || this.getRequestURI(reloadUri);
154+
155+
// If there are no valid manifest URIs, we should stop content steering.
156+
if (!uri) {
157+
this.logger_('No valid content steering manifest URIs. Stopping content steering.');
158+
this.trigger('error');
159+
this.dispose();
160+
return;
161+
}
162+
163+
this.request_ = this.xhr_({
140164
uri
141-
}, (error) => {
142-
// TODO: HLS CASES THAT NEED ADDRESSED:
143-
// If the client receives HTTP 410 Gone in response to a manifest request,
144-
// it MUST NOT issue another request for that URI for the remainder of the
145-
// playback session. It MAY continue to use the most-recently obtained set
146-
// of Pathways.
147-
// If the client receives HTTP 429 Too Many Requests with a Retry-After
148-
// header in response to a manifest request, it SHOULD wait until the time
149-
// specified by the Retry-After header to reissue the request.
165+
}, (error, errorInfo) => {
150166
if (error) {
151-
// TODO: HLS RETRY CASE:
167+
// If the client receives HTTP 410 Gone in response to a manifest request,
168+
// it MUST NOT issue another request for that URI for the remainder of the
169+
// playback session. It MAY continue to use the most-recently obtained set
170+
// of Pathways.
171+
if (errorInfo.status === 410) {
172+
this.logger_(`manifest request 410 ${error}.`);
173+
this.logger_(`There will be no more content steering requests to ${uri} this session.`);
174+
175+
this.excludedSteeringManifestURLs.add(uri);
176+
return;
177+
}
178+
// If the client receives HTTP 429 Too Many Requests with a Retry-After
179+
// header in response to a manifest request, it SHOULD wait until the time
180+
// specified by the Retry-After header to reissue the request.
181+
if (errorInfo.status === 429) {
182+
const retrySeconds = errorInfo.responseHeaders['retry-after'];
183+
184+
this.logger_(`manifest request 429 ${error}.`);
185+
this.logger_(`content steering will retry in ${retrySeconds} seconds.`);
186+
this.startTTLTimeout_(parseInt(retrySeconds, 10));
187+
return;
188+
}
152189
// If the Steering Manifest cannot be loaded and parsed correctly, the
153190
// client SHOULD continue to use the previous values and attempt to reload
154191
// it after waiting for the previously-specified TTL (or 5 minutes if
155192
// none).
156193
this.logger_(`manifest failed to load ${error}.`);
157-
// TODO: we may want to expose the error object here.
158-
this.trigger('error');
194+
this.startTTLTimeout_();
159195
return;
160196
}
161197
const steeringManifestJson = JSON.parse(this.request_.responseText);
162198

199+
this.startTTLTimeout_();
163200
this.assignSteeringProperties_(steeringManifestJson);
164201
});
165202
}
@@ -200,18 +237,18 @@ export default class ContentSteeringController extends videojs.EventTarget {
200237
setSteeringParams_(url) {
201238
const urlObject = new window.URL(url);
202239
const path = this.getPathway();
240+
const networkThroughput = this.getBandwidth_();
203241

204242
if (path) {
205243
const pathwayKey = `_${this.manifestType_}_pathway`;
206244

207245
urlObject.searchParams.set(pathwayKey, path);
208246
}
209247

210-
if (this.mainSegmentLoader_.throughput.rate) {
248+
if (networkThroughput) {
211249
const throughputKey = `_${this.manifestType_}_throughput`;
212-
const rateInteger = Math.round(this.mainSegmentLoader_.throughput.rate);
213250

214-
urlObject.searchParams.set(throughputKey, rateInteger);
251+
urlObject.searchParams.set(throughputKey, networkThroughput);
215252
}
216253
return urlObject.toString();
217254
}
@@ -234,44 +271,91 @@ export default class ContentSteeringController extends videojs.EventTarget {
234271
this.steeringManifest.priority = steeringJson['PATHWAY-PRIORITY'] || steeringJson['SERVICE-LOCATION-PRIORITY'];
235272
// TODO: HLS handle PATHWAY-CLONES. See section 7.2 https://datatracker.ietf.org/doc/draft-pantos-hls-rfc8216bis/
236273

237-
// TODO: fully implement priority logic.
238274
// 1. apply first pathway from the array.
239-
// 2. if first first pathway doesn't exist in manifest, try next pathway.
275+
// 2. if first pathway doesn't exist in manifest, try next pathway.
240276
// a. if all pathways are exhausted, ignore the steering manifest priority.
241277
// 3. if segments fail from an established pathway, try all variants/renditions, then exclude the failed pathway.
242278
// a. exclude a pathway for a minimum of the last TTL duration. Meaning, from the next steering response,
243279
// the excluded pathway will be ignored.
244-
const chooseNextPathway = (pathways) => {
245-
for (const path of pathways) {
280+
// See excludePathway usage in excludePlaylist().
281+
282+
// If there are no available pathways, we need to stop content steering.
283+
if (!this.availablePathways_.size) {
284+
this.logger_('There are no available pathways for content steering. Ending content steering.');
285+
this.trigger('error');
286+
this.dispose();
287+
}
288+
289+
const chooseNextPathway = (pathwaysByPriority) => {
290+
for (const path of pathwaysByPriority) {
246291
if (this.availablePathways_.has(path)) {
247292
return path;
248293
}
249294
}
295+
296+
// If no pathway matches, ignore the manifest and choose the first available.
297+
return [...this.availablePathways_][0];
250298
};
299+
251300
const nextPathway = chooseNextPathway(this.steeringManifest.priority);
252301

253302
if (this.currentPathway !== nextPathway) {
254303
this.currentPathway = nextPathway;
255304
this.trigger('content-steering');
256305
}
257-
this.startTTLTimeout_();
258306
}
259307

260308
/**
261309
* Returns the pathway to use for steering decisions
262310
*
263-
* @return returns the current pathway or the default
311+
* @return {string} returns the current pathway or the default
264312
*/
265313
getPathway() {
266314
return this.currentPathway || this.defaultPathway;
267315
}
268316

317+
/**
318+
* Chooses the manifest request URI based on proxy URIs and server URLs.
319+
* Also accounts for exclusion on certain manifest URIs.
320+
*
321+
* @param {string} reloadUri the base uri before parameters
322+
*
323+
* @return {string} the final URI for the request to the manifest server.
324+
*/
325+
getRequestURI(reloadUri) {
326+
if (!reloadUri) {
327+
return null;
328+
}
329+
330+
const isExcluded = (uri) => this.excludedSteeringManifestURLs.has(uri);
331+
332+
if (this.proxyServerUrl_) {
333+
const proxyURI = this.setProxyServerUrl_(reloadUri);
334+
335+
if (!isExcluded(proxyURI)) {
336+
return proxyURI;
337+
}
338+
}
339+
340+
const steeringURI = this.setSteeringParams_(reloadUri);
341+
342+
if (!isExcluded(steeringURI)) {
343+
return steeringURI;
344+
}
345+
346+
// Return nothing if all valid manifest URIs are excluded.
347+
return null;
348+
}
349+
269350
/**
270351
* Start the timeout for re-requesting the steering manifest at the TTL interval.
352+
*
353+
* @param {number} ttl time in seconds of the timeout. Defaults to the
354+
* ttl interval in the steering manifest
271355
*/
272-
startTTLTimeout_() {
356+
startTTLTimeout_(ttl = this.steeringManifest.ttl) {
273357
// 300 (5 minutes) is the default value.
274-
const ttlMS = this.steeringManifest.ttl * 1000;
358+
const ttlMS = ttl * 1000;
275359

276360
this.ttlTimeout_ = window.setTimeout(() => {
277361
this.requestSteeringManifest();
@@ -300,6 +384,8 @@ export default class ContentSteeringController extends videojs.EventTarget {
300384
* aborts steering requests clears the ttl timeout and resets all properties.
301385
*/
302386
dispose() {
387+
this.off('content-steering');
388+
this.off('error');
303389
this.abort();
304390
this.clearTTLTimeout_();
305391
this.currentPathway = null;
@@ -309,6 +395,7 @@ export default class ContentSteeringController extends videojs.EventTarget {
309395
this.manifestType_ = null;
310396
this.ttlTimeout_ = null;
311397
this.request_ = null;
398+
this.excludedSteeringManifestURLs = new Set();
312399
this.availablePathways_ = new Set();
313400
this.excludedPathways_ = new Set();
314401
this.steeringManifest = new SteeringManifest();
@@ -320,6 +407,19 @@ export default class ContentSteeringController extends videojs.EventTarget {
320407
* @param {string} pathway the pathway string to add
321408
*/
322409
addAvailablePathway(pathway) {
323-
this.availablePathways_.add(pathway);
410+
if (pathway) {
411+
this.availablePathways_.add(pathway);
412+
}
413+
}
414+
415+
/**
416+
* clears all pathways from the available pathways set
417+
*/
418+
clearAvailablePathways() {
419+
this.availablePathways_.clear();
420+
}
421+
422+
excludePathway(pathway) {
423+
return this.availablePathways_.delete(pathway);
324424
}
325425
}

src/dash-playlist-loader.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,10 @@ export default class DashPlaylistLoader extends EventTarget {
325325

326326
// live playlist staleness timeout
327327
this.on('mediaupdatetimeout', () => {
328-
this.refreshMedia_(this.media().id);
328+
// We handle live content steering in the playlist controller
329+
if (!this.media().attributes.serviceLocation) {
330+
this.refreshMedia_(this.media().id);
331+
}
329332
});
330333

331334
this.state = 'HAVE_NOTHING';

src/media-groups.js

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -250,13 +250,10 @@ export const onError = {
250250
*/
251251
AUDIO: (type, settings) => () => {
252252
const {
253-
segmentLoaders: { [type]: segmentLoader},
254253
mediaTypes: { [type]: mediaType },
255254
excludePlaylist
256255
} = settings;
257256

258-
stopLoaders(segmentLoader, mediaType);
259-
260257
// switch back to default audio track
261258
const activeTrack = mediaType.activeTrack();
262259
const activeGroup = mediaType.activeGroup();
@@ -295,15 +292,12 @@ export const onError = {
295292
*/
296293
SUBTITLES: (type, settings) => () => {
297294
const {
298-
segmentLoaders: { [type]: segmentLoader},
299295
mediaTypes: { [type]: mediaType }
300296
} = settings;
301297

302298
videojs.log.warn('Problem encountered loading the subtitle track.' +
303299
'Disabling subtitle track.');
304300

305-
stopLoaders(segmentLoader, mediaType);
306-
307301
const track = mediaType.activeTrack();
308302

309303
if (track) {

0 commit comments

Comments
 (0)