From b75ca1df63181976e1a39977360d25fc9a2e43e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Velad=20Galv=C3=A1n?= Date: Wed, 15 Nov 2023 11:10:37 +0100 Subject: [PATCH] feat(HLS): Add support for Content Steering (#5881) Closes https://github.com/shaka-project/shaka-player/issues/5704 --- lib/hls/hls_parser.js | 158 ++++++++++++++++++----- lib/util/content_steering_manager.js | 10 +- test/hls/hls_parser_unit.js | 70 ++++++++++ test/test/util/fake_networking_engine.js | 10 +- 4 files changed, 207 insertions(+), 41 deletions(-) diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 8103de40eb..621a41d1da 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -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'); @@ -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; } @@ -236,6 +240,10 @@ shaka.hls.HlsParser = class { */ configure(config) { this.config_ = config; + + if (this.contentSteeringManager_) { + this.contentSteeringManager_.configure(this.config_); + } } /** @@ -288,6 +296,10 @@ shaka.hls.HlsParser = class { this.groupIdToCodecsMap_.clear(); this.globalVariables_.clear(); + if (this.contentSteeringManager_) { + this.contentSteeringManager_.destroy(); + } + return Promise.all(pending); } @@ -501,7 +513,9 @@ shaka.hls.HlsParser = class { * @exportInterface */ banLocation(uri) { - // No-op + if (this.contentSteeringManager_) { + this.contentSteeringManager_.banLocation(uri); + } } /** @@ -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') { @@ -776,8 +790,12 @@ shaka.hls.HlsParser = class { /** @type {!Array.} */ const sessionDataTags = Utils.filterTagsByName( playlist.tags, 'EXT-X-SESSION-DATA'); + /** @type {!Array.} */ + const contentSteeringTags = Utils.filterTagsByName( + playlist.tags, 'EXT-X-CONTENT-STEERING'); this.processSessionData_(sessionDataTags); + await this.processContentSteering_(contentSteeringTags); this.parseCodecs_(variantTags); @@ -1107,6 +1125,38 @@ shaka.hls.HlsParser = class { } } + /** + * Process EXT-X-CONTENT-STEERING tags. + * + * @param {!Array.} 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. @@ -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; @@ -1203,9 +1253,10 @@ shaka.hls.HlsParser = class { /** * @param {!Array.} mediaTags Media tags from the playlist. + * @param {!Map.} groupIdPathwayIdMapping * @private */ - createStreamInfosFromMediaTags_(mediaTags) { + createStreamInfosFromMediaTags_(mediaTags, groupIdPathwayIdMapping) { // Filter out subtitles and media tags without uri. mediaTags = mediaTags.filter((tag) => { const uri = tag.getAttributeValue('URI') || ''; @@ -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); } } @@ -1351,6 +1403,7 @@ shaka.hls.HlsParser = class { audio: [], video: [], }; + const groupIdPathwayIdMapping = new Map(); const globalGroupIds = []; let isAudioGroup = false; let isVideoGroup = false; @@ -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) { @@ -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 = @@ -1770,17 +1828,22 @@ shaka.hls.HlsParser = class { * Parse EXT-X-MEDIA media tag into a Stream object. * * @param {!Array.} tags + * @param {!Map.} 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(','); @@ -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'); @@ -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; } @@ -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); @@ -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); @@ -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(','); @@ -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, @@ -1993,6 +2072,7 @@ shaka.hls.HlsParser = class { /** + * @param {number} streamId * @param {!Array.} verbatimMediaPlaylistUris * @param {string} codecs * @param {string} type @@ -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, @@ -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) { @@ -2315,6 +2400,7 @@ shaka.hls.HlsParser = class { } /** + * @param {number} streamId * @param {!shaka.hls.Playlist} playlist * @param {function():!Array.} getUris * @param {string} responseUri @@ -2333,7 +2419,7 @@ shaka.hls.HlsParser = class { * @return {!Promise.} * @private */ - async convertParsedPlaylistIntoStreamInfo_(playlist, + async convertParsedPlaylistIntoStreamInfo_(streamId, playlist, getUris, responseUri, codecs, type, languageValue, primary, name, channelsCount, closedCaptions, characteristics, forced, sampleRate, spatialAudio, mimeType = undefined) { @@ -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; @@ -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 @@ -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) || diff --git a/lib/util/content_steering_manager.js b/lib/util/content_steering_manager.js index e1061f1dde..ad9068a374 100644 --- a/lib/util/content_steering_manager.js +++ b/lib/util/content_steering_manager.js @@ -58,7 +58,7 @@ shaka.util.ContentSteeringManager = class { */ this.lastTTL_ = 300; - /** @private {!Map.>} */ + /** @private {!Map.<(string|number), !Map.>} */ this.locations_ = new Map(); /** @private {!Map.} */ @@ -195,7 +195,7 @@ shaka.util.ContentSteeringManager = class { this.requestInfo(uri); }); if (manifest.TTL) { - this.lastTTL_ = 1; + this.lastTTL_ = manifest.TTL; } this.updateTimer_.tickAfter(this.lastTTL_); this.pathwayPriority_ = manifest['PATHWAY-PRIORITY'] || []; @@ -210,7 +210,7 @@ shaka.util.ContentSteeringManager = class { } /** - * @param {string} streamId + * @param {string|number} streamId * @param {string} pathwayId * @param {string} uri */ @@ -234,7 +234,7 @@ shaka.util.ContentSteeringManager = class { /** * Get the base locations ordered according the priority. * - * @param {string} streamId + * @param {string|number} streamId * @return {!Array.} */ getLocations(streamId) { @@ -277,7 +277,7 @@ shaka.util.ContentSteeringManager = class { } locationsPathwayIdMap = locationsPathwayIdMap.filter((l) => { for (const uri of this.bannedLocations_.keys()) { - if (uri.includes(l.location)) { + if (uri.includes(new goog.Uri(l.location).getDomain())) { return false; } } diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index da3fbb95cf..120d8c3aaf 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -5160,4 +5160,74 @@ describe('HlsParser', () => { expect(video.width).toBe(256); expect(video.height).toBe(110); }); + + it('supports ContentSteering', async () => { + const master = [ + '#EXTM3U\n', + '#EXT-X-CONTENT-STEERING:SERVER-URI="http://contentsteering",', + 'PATHWAY-ID="a"\n', + '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="a",LANGUAGE="eng",', + 'URI="audio/a/media.m3u8"\n', + '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="b",LANGUAGE="eng",', + 'URI="audio/b/media.m3u8"\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc,mp4a",', + 'AUDIO="a",PATHWAY-ID="a"\n', + 'a/media.m3u8\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc,mp4a",', + 'AUDIO="b",PATHWAY-ID="b"\n', + 'b/media.m3u8', + ].join(''); + + const media = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXTINF:5,\n', + '#EXT-X-BYTERANGE:121090@616\n', + 'main.mp4', + ].join(''); + + const contentSteering = JSON.stringify({ + 'VERSION': 1, + 'TTL': 1, + 'RELOAD-URI': 'http://contentsteering/update', + 'PATHWAY-PRIORITY': [ + 'b', + 'a', + ], + }); + + fakeNetEngine + .setResponseText('http://master', master) + .setResponseText('http://contentsteering', contentSteering) + .setResponseText('http://master/a/media.m3u8', media) + .setResponseText('http://master/b/media.m3u8', media) + .setResponseText('http://master/audio/a/media.m3u8', media) + .setResponseText('http://master/audio/b/media.m3u8', media) + .setMaxUris(2); + + /** @type {shaka.extern.Manifest} */ + const manifest = await parser.start('http://master', playerInterface); + expect(manifest.variants.length).toBe(1); + + const audio0 = manifest.variants[0].audio; + await audio0.createSegmentIndex(); + goog.asserts.assert(audio0.segmentIndex, 'Null segmentIndex!'); + const audioSegment0 = Array.from(audio0.segmentIndex)[0]; + const audioUri0 = audioSegment0.getUris()[0]; + const audioUri1 = audioSegment0.getUris()[1]; + + expect(audioUri0).toBe('http://master/audio/b/main.mp4'); + expect(audioUri1).toBe('http://master/audio/a/main.mp4'); + + const video0 = manifest.variants[0].video; + await video0.createSegmentIndex(); + goog.asserts.assert(video0.segmentIndex, 'Null segmentIndex!'); + const videoSegment0 = Array.from(video0.segmentIndex)[0]; + const videoUri0 = videoSegment0.getUris()[0]; + const videoUri1 = videoSegment0.getUris()[1]; + + expect(videoUri0).toBe('http://master/b/main.mp4'); + expect(videoUri1).toBe('http://master/a/main.mp4'); + }); }); diff --git a/test/test/util/fake_networking_engine.js b/test/test/util/fake_networking_engine.js index e09539a1e4..3fee8263d1 100644 --- a/test/test/util/fake_networking_engine.js +++ b/test/test/util/fake_networking_engine.js @@ -49,6 +49,9 @@ shaka.test.FakeNetworkingEngine = class { /** @type {!jasmine.Spy} */ this.setForceHTTPS = jasmine.createSpy('setForceHTTPS').and.stub(); + /** @private {number} */ + this.maxUris_ = 1; + // The prototype has already been applied; create spies for the // methods but still call it by default. spyOn(this, 'destroy').and.callThrough(); @@ -67,7 +70,7 @@ shaka.test.FakeNetworkingEngine = class { */ requestImpl_(type, request) { expect(request).toBeTruthy(); - expect(request.uris.length).toBe(1); + expect(request.uris.length).toBeLessThanOrEqual(this.maxUris_); const requestedUri = request.uris[0]; @@ -288,6 +291,11 @@ shaka.test.FakeNetworkingEngine = class { return this; } + setMaxUris(maxUris) { + this.maxUris_ = maxUris; + return this; + } + /** * Expects that a request for the given segment has occurred. *