Skip to content

Commit

Permalink
feat(HLS): Add support for Content Steering (#5881)
Browse files Browse the repository at this point in the history
Closes #5704
  • Loading branch information
avelad authored Nov 15, 2023
1 parent 1c6f1fa commit b75ca1d
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 41 deletions.
158 changes: 123 additions & 35 deletions lib/hls/hls_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ goog.require('shaka.net.DataUriPlugin');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.ArrayUtils');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.ContentSteeringManager');
goog.require('shaka.util.Error');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.LanguageUtils');
Expand Down Expand Up @@ -227,6 +228,9 @@ shaka.hls.HlsParser = class {
* @private {!shaka.abr.Ewma}
*/
this.averageUpdateDuration_ = new shaka.abr.Ewma(5);

/** @private {?shaka.util.ContentSteeringManager} */
this.contentSteeringManager_ = null;
}


Expand All @@ -236,6 +240,10 @@ shaka.hls.HlsParser = class {
*/
configure(config) {
this.config_ = config;

if (this.contentSteeringManager_) {
this.contentSteeringManager_.configure(this.config_);
}
}

/**
Expand Down Expand Up @@ -288,6 +296,10 @@ shaka.hls.HlsParser = class {
this.groupIdToCodecsMap_.clear();
this.globalVariables_.clear();

if (this.contentSteeringManager_) {
this.contentSteeringManager_.destroy();
}

return Promise.all(pending);
}

Expand Down Expand Up @@ -501,7 +513,9 @@ shaka.hls.HlsParser = class {
* @exportInterface
*/
banLocation(uri) {
// No-op
if (this.contentSteeringManager_) {
this.contentSteeringManager_.banLocation(uri);
}
}

/**
Expand Down Expand Up @@ -731,9 +745,9 @@ shaka.hls.HlsParser = class {

// Make the stream info, with those values.
const streamInfo = await this.convertParsedPlaylistIntoStreamInfo_(
playlist, getUris, uri, codecs, type, languageValue, primary, name,
channelsCount, closedCaptions, characteristics, forced, sampleRate,
spatialAudio, mimeType);
this.globalId_++, playlist, getUris, uri, codecs, type,
languageValue, primary, name, channelsCount, closedCaptions,
characteristics, forced, sampleRate, spatialAudio, mimeType);
this.uriToStreamInfosMap_.set(uri, streamInfo);

if (type == 'video') {
Expand Down Expand Up @@ -776,8 +790,12 @@ shaka.hls.HlsParser = class {
/** @type {!Array.<!shaka.hls.Tag>} */
const sessionDataTags = Utils.filterTagsByName(
playlist.tags, 'EXT-X-SESSION-DATA');
/** @type {!Array.<!shaka.hls.Tag>} */
const contentSteeringTags = Utils.filterTagsByName(
playlist.tags, 'EXT-X-CONTENT-STEERING');

this.processSessionData_(sessionDataTags);
await this.processContentSteering_(contentSteeringTags);

this.parseCodecs_(variantTags);

Expand Down Expand Up @@ -1107,6 +1125,38 @@ shaka.hls.HlsParser = class {
}
}

/**
* Process EXT-X-CONTENT-STEERING tags.
*
* @param {!Array.<!shaka.hls.Tag>} tags
* @return {!Promise}
* @private
*/
async processContentSteering_(tags) {
if (!this.playerInterface_ || !this.config_) {
return;
}
let contentSteeringPromise;
for (const tag of tags) {
const defaultPathwayId = tag.getAttributeValue('PATHWAY-ID');
const uri = tag.getAttributeValue('SERVER-URI');
if (!defaultPathwayId || !uri) {
continue;
}
this.contentSteeringManager_ =
new shaka.util.ContentSteeringManager(this.playerInterface_);
this.contentSteeringManager_.configure(this.config_);
this.contentSteeringManager_.setBaseUris([this.masterPlaylistUri_]);
this.contentSteeringManager_.setManifestType(
shaka.media.ManifestParser.HLS);
this.contentSteeringManager_.setDefaultPathwayId(defaultPathwayId);
contentSteeringPromise =
this.contentSteeringManager_.requestInfo(uri);
break;
}
await contentSteeringPromise;
}

/**
* Parse Subtitles and Closed Captions from 'EXT-X-MEDIA' tags.
* Create text streams for Subtitles, but not Closed Captions.
Expand All @@ -1125,7 +1175,7 @@ shaka.hls.HlsParser = class {
return null;
}
try {
return this.createStreamInfoFromMediaTags_([tag]).stream;
return this.createStreamInfoFromMediaTags_([tag], new Map()).stream;
} catch (e) {
if (this.config_.hls.ignoreTextStreamFailures) {
return null;
Expand Down Expand Up @@ -1203,9 +1253,10 @@ shaka.hls.HlsParser = class {

/**
* @param {!Array.<!shaka.hls.Tag>} mediaTags Media tags from the playlist.
* @param {!Map.<string, string>} groupIdPathwayIdMapping
* @private
*/
createStreamInfosFromMediaTags_(mediaTags) {
createStreamInfosFromMediaTags_(mediaTags, groupIdPathwayIdMapping) {
// Filter out subtitles and media tags without uri.
mediaTags = mediaTags.filter((tag) => {
const uri = tag.getAttributeValue('URI') || '';
Expand All @@ -1225,7 +1276,8 @@ shaka.hls.HlsParser = class {

for (const key in groupedTags) {
// Create stream info for each audio / video media grouped tag.
this.createStreamInfoFromMediaTags_(groupedTags[key]);
this.createStreamInfoFromMediaTags_(
groupedTags[key], groupIdPathwayIdMapping);
}
}

Expand Down Expand Up @@ -1351,6 +1403,7 @@ shaka.hls.HlsParser = class {
audio: [],
video: [],
};
const groupIdPathwayIdMapping = new Map();
const globalGroupIds = [];
let isAudioGroup = false;
let isVideoGroup = false;
Expand All @@ -1367,6 +1420,10 @@ shaka.hls.HlsParser = class {
if (!globalGroupIds.includes(groupId)) {
globalGroupIds.push(groupId);
}
const pathwayId = tag.getAttributeValue('PATHWAY-ID');
if (pathwayId) {
groupIdPathwayIdMapping.set(groupId, pathwayId);
}
if (audioGroupId) {
isAudioGroup = true;
} else if (videoGroupId) {
Expand All @@ -1379,7 +1436,8 @@ shaka.hls.HlsParser = class {
const mediaTagsForVariant = mediaTags.filter((tag) => {
return globalGroupIds.includes(tag.getRequiredAttrValue('GROUP-ID'));
});
this.createStreamInfosFromMediaTags_(mediaTagsForVariant);
this.createStreamInfosFromMediaTags_(
mediaTagsForVariant, groupIdPathwayIdMapping);
}
const globalGroupId = globalGroupIds.sort().join(',');
const streamInfos =
Expand Down Expand Up @@ -1770,17 +1828,22 @@ shaka.hls.HlsParser = class {
* Parse EXT-X-MEDIA media tag into a Stream object.
*
* @param {!Array.<!shaka.hls.Tag>} tags
* @param {!Map.<string, string>} groupIdPathwayIdMapping
* @return {!shaka.hls.HlsParser.StreamInfo}
* @private
*/
createStreamInfoFromMediaTags_(tags) {
createStreamInfoFromMediaTags_(tags, groupIdPathwayIdMapping) {
const verbatimMediaPlaylistUris = [];
const globalGroupIds = [];
const groupIdUriMappping = new Map();
for (const tag of tags) {
goog.asserts.assert(tag.name == 'EXT-X-MEDIA',
'Should only be called on media tags!');
verbatimMediaPlaylistUris.push(tag.getRequiredAttrValue('URI'));
globalGroupIds.push(tag.getRequiredAttrValue('GROUP-ID'));
const uri = tag.getRequiredAttrValue('URI');
const groupId = tag.getRequiredAttrValue('GROUP-ID');
verbatimMediaPlaylistUris.push(uri);
globalGroupIds.push(groupId);
groupIdUriMappping.set(groupId, uri);
}

const globalGroupId = globalGroupIds.sort().join(',');
Expand All @@ -1805,6 +1868,15 @@ shaka.hls.HlsParser = class {
if (this.uriToStreamInfosMap_.has(key)) {
return this.uriToStreamInfosMap_.get(key);
}
const streamId = this.globalId_++;
if (this.contentSteeringManager_) {
for (const [groupId, uri] of groupIdUriMappping) {
const pathwayId = groupIdPathwayIdMapping.get(groupId);
if (pathwayId) {
this.contentSteeringManager_.addLocation(streamId, pathwayId, uri);
}
}
}

const language = firstTag.getAttributeValue('LANGUAGE');
const name = firstTag.getAttributeValue('NAME');
Expand All @@ -1830,9 +1902,9 @@ shaka.hls.HlsParser = class {
// TODO: Should we take into account some of the currently ignored
// attributes: INSTREAM-ID, Attribute descriptions: https://bit.ly/2lpjOhj
const streamInfo = this.createStreamInfo_(
verbatimMediaPlaylistUris, codecs, type, language, primary, name,
channelsCount, /* closedCaptions= */ null, characteristics, forced,
sampleRate, spatialAudio);
streamId, verbatimMediaPlaylistUris, codecs, type, language,
primary, name, channelsCount, /* closedCaptions= */ null,
characteristics, forced, sampleRate, spatialAudio);
if (streamInfo.stream) {
streamInfo.stream.groupId = globalGroupId;
}
Expand Down Expand Up @@ -1874,7 +1946,7 @@ shaka.hls.HlsParser = class {
const characteristics = tag.getAttributeValue('CHARACTERISTICS');

const streamInfo = this.createStreamInfo_(
[verbatimImagePlaylistUri], codecs, type, language,
this.globalId_++, [verbatimImagePlaylistUri], codecs, type, language,
/* primary= */ false, name, /* channelsCount= */ null,
/* closedCaptions= */ null, characteristics, /* forced= */ false,
/* sampleRate= */ null, /* spatialAudio= */ false);
Expand Down Expand Up @@ -1938,7 +2010,7 @@ shaka.hls.HlsParser = class {
const characteristics = tag.getAttributeValue('CHARACTERISTICS');

const streamInfo = this.createStreamInfo_(
[verbatimIFramePlaylistUri], codecs, type, language,
this.globalId_++, [verbatimIFramePlaylistUri], codecs, type, language,
/* primary= */ false, name, /* channelsCount= */ null,
/* closedCaptions= */ null, characteristics, /* forced= */ false,
/* sampleRate= */ null, /* spatialAudio= */ false);
Expand Down Expand Up @@ -1967,11 +2039,17 @@ shaka.hls.HlsParser = class {
* @private
*/
createStreamInfoFromVariantTags_(tags, allCodecs, type) {
const streamId = this.globalId_++;
const verbatimMediaPlaylistUris = [];
for (const tag of tags) {
goog.asserts.assert(tag.name == 'EXT-X-STREAM-INF',
'Should only be called on variant tags!');
verbatimMediaPlaylistUris.push(tag.getRequiredAttrValue('URI'));
const uri = tag.getRequiredAttrValue('URI');
const pathwayId = tag.getAttributeValue('PATHWAY-ID');
if (this.contentSteeringManager_ && pathwayId) {
this.contentSteeringManager_.addLocation(streamId, pathwayId, uri);
}
verbatimMediaPlaylistUris.push(uri);
}

const key = verbatimMediaPlaylistUris.sort().join(',');
Expand All @@ -1981,7 +2059,8 @@ shaka.hls.HlsParser = class {

const closedCaptions = this.getClosedCaptions_(tags[0], type);
const codecs = shaka.util.ManifestParserUtils.guessCodecs(type, allCodecs);
const streamInfo = this.createStreamInfo_(verbatimMediaPlaylistUris,
const streamInfo = this.createStreamInfo_(
streamId, verbatimMediaPlaylistUris,
codecs, type, /* language= */ null, /* primary= */ false,
/* name= */ null, /* channelcount= */ null, closedCaptions,
/* characteristics= */ null, /* forced= */ false,
Expand All @@ -1993,6 +2072,7 @@ shaka.hls.HlsParser = class {


/**
* @param {number} streamId
* @param {!Array.<string>} verbatimMediaPlaylistUris
* @param {string} codecs
* @param {string} type
Expand All @@ -2008,23 +2088,28 @@ shaka.hls.HlsParser = class {
* @return {!shaka.hls.HlsParser.StreamInfo}
* @private
*/
createStreamInfo_(verbatimMediaPlaylistUris, codecs, type, languageValue,
primary, name, channelsCount, closedCaptions, characteristics, forced,
sampleRate, spatialAudio) {
createStreamInfo_(streamId, verbatimMediaPlaylistUris, codecs, type,
languageValue, primary, name, channelsCount, closedCaptions,
characteristics, forced, sampleRate, spatialAudio) {
// TODO: Refactor, too many parameters

// This stream is lazy-loaded inside the createSegmentIndex function.
// So we start out with a stream object that does not contain the actual
// segment index, then download when createSegmentIndex is called.
const stream = this.makeStreamObject_(streamId, codecs, type,
languageValue, primary, name, channelsCount, closedCaptions,
characteristics, forced, sampleRate, spatialAudio);

const redirectUris = [];
const getUris = () => {
if (this.contentSteeringManager_ &&
verbatimMediaPlaylistUris.length > 1) {
return this.contentSteeringManager_.getLocations(streamId);
}
return redirectUris.concat(shaka.hls.Utils.constructUris(
[this.masterPlaylistUri_], verbatimMediaPlaylistUris,
this.globalVariables_));
};

// This stream is lazy-loaded inside the createSegmentIndex function.
// So we start out with a stream object that does not contain the actual
// segment index, then download when createSegmentIndex is called.
const stream = this.makeStreamObject_(codecs, type, languageValue, primary,
name, channelsCount, closedCaptions, characteristics, forced,
sampleRate, spatialAudio);
const streamInfo = {
stream,
type,
Expand Down Expand Up @@ -2081,7 +2166,7 @@ shaka.hls.HlsParser = class {

const wasLive = this.isLive_();
const realStreamInfo = await this.convertParsedPlaylistIntoStreamInfo_(
playlist, getUris, responseUri, codecs,
streamId, playlist, getUris, responseUri, codecs,
type, languageValue, primary, name, channelsCount, closedCaptions,
characteristics, forced, sampleRate, spatialAudio, mimeType);
if (abortSignal.aborted) {
Expand Down Expand Up @@ -2315,6 +2400,7 @@ shaka.hls.HlsParser = class {
}

/**
* @param {number} streamId
* @param {!shaka.hls.Playlist} playlist
* @param {function():!Array.<string>} getUris
* @param {string} responseUri
Expand All @@ -2333,7 +2419,7 @@ shaka.hls.HlsParser = class {
* @return {!Promise.<!shaka.hls.HlsParser.StreamInfo>}
* @private
*/
async convertParsedPlaylistIntoStreamInfo_(playlist,
async convertParsedPlaylistIntoStreamInfo_(streamId, playlist,
getUris, responseUri, codecs, type, languageValue, primary, name,
channelsCount, closedCaptions, characteristics, forced, sampleRate,
spatialAudio, mimeType = undefined) {
Expand Down Expand Up @@ -2377,9 +2463,9 @@ shaka.hls.HlsParser = class {
shaka.util.Error.Code.HLS_KEYFORMATS_NOT_SUPPORTED);
}

const stream = this.makeStreamObject_(codecs, type, languageValue, primary,
name, channelsCount, closedCaptions, characteristics, forced,
sampleRate, spatialAudio);
const stream = this.makeStreamObject_(streamId, codecs, type,
languageValue, primary, name, channelsCount, closedCaptions,
characteristics, forced, sampleRate, spatialAudio);
stream.encrypted = encrypted;
stream.drmInfos = drmInfos;
stream.keyIds = keyIds;
Expand Down Expand Up @@ -2485,6 +2571,7 @@ shaka.hls.HlsParser = class {
* The parameters that are passed into here are only the things that can be
* known without downloading the media playlist; other values must be set
* manually on the object after creation.
* @param {number} id
* @param {string} codecs
* @param {string} type
* @param {?string} languageValue
Expand All @@ -2499,8 +2586,9 @@ shaka.hls.HlsParser = class {
* @return {!shaka.extern.Stream}
* @private
*/
makeStreamObject_(codecs, type, languageValue, primary, name, channelsCount,
closedCaptions, characteristics, forced, sampleRate, spatialAudio) {
makeStreamObject_(id, codecs, type, languageValue, primary, name,
channelsCount, closedCaptions, characteristics, forced, sampleRate,
spatialAudio) {
// Fill out a "best-guess" mimeType, for now. It will be replaced once the
// stream is lazy-loaded.
const mimeType = this.guessMimeTypeBeforeLoading_(type, codecs) ||
Expand Down
Loading

0 comments on commit b75ca1d

Please sign in to comment.