diff --git a/.gitignore b/.gitignore index 06fa1c1ea8..ff798aa296 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ docs/api/ coverage/ .DS_Store +.vscode diff --git a/AUTHORS b/AUTHORS index 398cc67213..08a07a68e6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -70,5 +70,6 @@ Tomohiro Matsuzawa Toshihiro Suzuki uStudio Inc. <*@ustudio.com> Verizon Digital Media Services <*@verizondigitalmedia.com> +ViacomCBS <*@viacomcbs.com> Vincent Valot Wayne Morgan diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 76fecadca5..0ae7c916f3 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -36,6 +36,7 @@ Benjamin Wallberg Boris Cupac Brad Nadler Bryan Huh +Casey Occhialini Chad Assareh Chris Fillmore Costel Madalin Grecu diff --git a/build/types/core b/build/types/core index 30d61997d6..0d7e59b9b8 100644 --- a/build/types/core +++ b/build/types/core @@ -58,6 +58,7 @@ +../../lib/util/abortable_operation.js +../../lib/util/array_utils.js +../../lib/util/buffer_utils.js ++../../lib/util/cmcd_manager.js +../../lib/util/config_utils.js +../../lib/util/data_view_reader.js +../../lib/util/delayed_tick.js diff --git a/demo/common/message_ids.js b/demo/common/message_ids.js index b498dce15b..a9719d1ce4 100644 --- a/demo/common/message_ids.js +++ b/demo/common/message_ids.js @@ -157,7 +157,9 @@ shakaDemo.MessageIds = { BUFFER_BEHIND: 'DEMO_BUFFER_BEHIND', BUFFERING_GOAL: 'DEMO_BUFFERING_GOAL', CLOCK_SYNC_URI: 'DEMO_CLOCK_SYNC_URI', + CMCD_SECTION_HEADER: 'DEMO_CMCD_SECTION_HEADER', CONNECTION_TIMEOUT: 'DEMO_CONNECTION_TIMEOUT', + CONTENT_ID: 'DEMO_CONTENT_ID', DEFAULT_AUDIO_CODEC: 'DEMO_DEFAULT_AUDIO_CODEC', DEFAULT_PRESENTATION_DELAY: 'DEMO_DEFAULT_PRESENTATION_DELAY', DEFAULT_VIDEO_CODEC: 'DEMO_DEFAULT_VIDEO_CODEC', @@ -226,6 +228,7 @@ shakaDemo.MessageIds = { RESTRICTIONS_SECTION_HEADER: 'DEMO_RESTRICTIONS_SECTION_HEADER', SAFE_SEEK_OFFSET: 'DEMO_SAFE_SEEK_OFFSET', SAFE_SKIP_DISTANCE: 'DEMO_SAFE_SKIP_DISTANCE', + SESSION_ID: 'DEMO_SESSION_ID', SHAKA_CONTROLS: 'DEMO_SHAKA_CONTROLS', SLOW_HALF_LIFE: 'DEMO_SLOW_HALF_LIFE', STALL_DETECTOR_ENABLED: 'DEMO_STALL_DETECTOR_ENABLED', @@ -243,6 +246,7 @@ shakaDemo.MessageIds = { UI_LOCALE: 'DEMO_UI_LOCALE', UPDATE_EXPIRATION_TIME: 'DEMO_UPDATE_EXPIRATION_TIME', UPDATE_INTERVAL_SECONDS: 'DEMO_UPDATE_INTERVAL_SECONDS', + USE_HEADERS: 'DEMO_USE_HEADERS', USE_NATIVE_HLS_SAFARI: 'DEMO_USE_NATIVE_HLS_SAFARI', USE_PERSISTENT_LICENSES: 'DEMO_USE_PERSISTENT_LICENSES', VIDEO_ROBUSTNESS: 'DEMO_VIDEO_ROBUSTNESS', diff --git a/demo/config.js b/demo/config.js index 6f2ae9e9e7..f3d5c560fc 100644 --- a/demo/config.js +++ b/demo/config.js @@ -96,6 +96,7 @@ shakaDemo.Config = class { this.addManifestSection_(); this.addRetrictionsSection_('', shakaDemo.MessageIds.RESTRICTIONS_SECTION_HEADER); + this.addCmcdSection_(); } /** @@ -274,6 +275,17 @@ shakaDemo.Config = class { MessageIds.ADAPTATION_RESTRICTIONS_SECTION_HEADER); } + /** @private */ + addCmcdSection_() { + const MessageIds = shakaDemo.MessageIds; + const docLink = this.resolveExternLink_('.CmcdConfiguration'); + this.addSection_(MessageIds.CMCD_SECTION_HEADER, docLink) + .addBoolInput_(MessageIds.ENABLED, 'cmcd.enabled') + .addTextInput_(MessageIds.SESSION_ID, 'cmcd.sessionId') + .addTextInput_(MessageIds.CONTENT_ID, 'cmcd.contentId') + .addBoolInput_(MessageIds.USE_HEADERS, 'cmcd.useHeaders'); + } + /** * @param {string} category * @param {!shakaDemo.MessageIds} sectionName diff --git a/demo/locales/en.json b/demo/locales/en.json index 6ea745ac12..c9af9a3021 100644 --- a/demo/locales/en.json +++ b/demo/locales/en.json @@ -33,11 +33,13 @@ "DEMO_CLEAR": "No DRM protection", "DEMO_CLEAR_KEY": "Clear Key DRM", "DEMO_CLOCK_SYNC_URI": "Clock Sync URI", + "DEMO_CMCD_SECTION_HEADER": "CMCD", "DEMO_COMPILED_DEBUG": "Compiled (Debug)", "DEMO_COMPILED_RELEASE": "Compiled (Release)", "DEMO_CONNECTION_TIMEOUT": "Connection timeout", "DEMO_CONFIG": "Shaka Player Demo Config", "DEMO_CONTAINER_SEARCH": "Container", + "DEMO_CONTENT_ID": "Content ID", "DEMO_CUSTOM": "Custom", "DEMO_CUSTOM_CONTENT": "CUSTOM CONTENT", "DEMO_CUSTOM_INTRO_ONE": "Try Shaka Player with your own content!", @@ -173,6 +175,7 @@ "DEMO_RESTRICTIONS_SECTION_HEADER": "Restrictions", "DEMO_SAFE_SEEK_OFFSET": "Safe Seek Offset", "DEMO_SAFE_SKIP_DISTANCE": "Safe Skip Distance", + "DEMO_SESSION_ID": "Session ID", "DEMO_SAVE_BUTTON": "Save", "DEMO_SHAKA": "Shaka", "DEMO_SHAKA_CONTROLS": "Shaka Controls", @@ -218,6 +221,7 @@ "DEMO_UPDATE_INTERVAL_SECONDS": "Update interval seconds", "DEMO_UPLYNK": "Verizon Digital Media Services", "DEMO_USE_FULL_SEGMENTS_FOR_START_TIME": "Use Full Segments For Start Time", + "DEMO_USE_HEADERS": "Use Headers", "DEMO_USE_NATIVE_HLS_SAFARI": "Use native HLS on Safari", "DEMO_USE_PERSISTENT_LICENSES": "Use Persistent Licenses", "DEMO_VIDEO_ROBUSTNESS": "Video Robustness", diff --git a/demo/locales/source.json b/demo/locales/source.json index aa2bda8b0b..55b76acd41 100644 --- a/demo/locales/source.json +++ b/demo/locales/source.json @@ -135,6 +135,10 @@ "description": "The name of a configuration value.", "message": "Clock Sync URI" }, + "DEMO_CMCD_SECTION_HEADER": { + "description": "The header for a section of configuration values.", + "message": "[JARGON:CMCD]" + }, "DEMO_COMPILED_DEBUG": { "description": "A link in the footer, to the debug build of the demo.", "message": "Compiled (Debug)" @@ -155,6 +159,10 @@ "description": "A header on a search field that filters by container type.", "message": "Container" }, + "DEMO_CONTENT_ID": { + "description": "The name of a configuration value.", + "message": "Content ID" + }, "DEMO_CUSTOM": { "description": "Text that describes an asset that was defined by the user.", "message": "Custom" @@ -703,6 +711,10 @@ "description": "A button to save a custom asset.", "message": "Save" }, + "DEMO_SESSION_ID": { + "description": "The name of a configuration value.", + "message": "Session ID" + }, "DEMO_SHAKA": { "description": "Text that describes an asset that comes from the Shaka Player asset library.", "message": "[PROPER_NAME:Shaka]" @@ -875,6 +887,10 @@ "description": "Text that describes an asset that comes from the Verizon Digital Media Services asset library.", "message": "[PROPER_NAME:Verizon Digital Media Services]" }, + "DEMO_USE_HEADERS": { + "description": "The name of a configuration value.", + "message": "Use Headers" + }, "DEMO_USE_NATIVE_HLS_SAFARI": { "description": "The name of a configuration value.", "message": "Use native [PROPER_NAME:HLS] on Safari" diff --git a/externs/shaka/manifest_parser.js b/externs/shaka/manifest_parser.js index 61fe1792dd..442136ad0a 100644 --- a/externs/shaka/manifest_parser.js +++ b/externs/shaka/manifest_parser.js @@ -100,6 +100,10 @@ shaka.extern.ManifestParser = class { /** * @typedef {{ * networkingEngine: !shaka.net.NetworkingEngine, + * modifyManifestRequest: function(!shaka.extern.Request, + * shaka.util.CmcdManager.ManifestInfo), + * modifySegmentRequest: function(!shaka.extern.Request, + * shaka.util.CmcdManager.SegmentInfo), * filter: function(shaka.extern.Manifest):!Promise, * makeTextStreamsForClosedCaptions: function(shaka.extern.Manifest), * onTimelineRegionAdded: function(shaka.extern.TimelineRegionInfo), @@ -118,6 +122,12 @@ shaka.extern.ManifestParser = class { * * @property {!shaka.net.NetworkingEngine} networkingEngine * The networking engine to use for network requests. + * @property {function(!shaka.extern.Request, + * shaka.util.CmcdManager.ManifestInfo)} modifyManifestRequest + * Modify a manifest request + * @property {function(!shaka.extern.Request, + * shaka.util.CmcdManager.SegmentInfo)} modifySegmentRequest + * Modify a segment request * @property {function(shaka.extern.Manifest):!Promise} filter * Should be called when new variants or text streams are added to the * Manifest. Note that this operation is asynchronous. @@ -150,4 +160,3 @@ shaka.extern.ManifestParser.PlayerInterface; * @exportDoc */ shaka.extern.ManifestParser.Factory; - diff --git a/externs/shaka/player.js b/externs/shaka/player.js index 21150cba28..4c5dfc3819 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -942,7 +942,7 @@ shaka.extern.StreamingConfiguration; * automatically, but will still appear in the track list and can still be * selected via selectVariantTrack(). If no tracks meet these * restrictions, AbrManager should not fail, but choose a low-res or - * low-bandwidth variant instead. It is the responsibiliy of AbrManager + * low-bandwidth variant instead. It is the responsibility of AbrManager * implementations to follow these rules and implement this behavior. * @property {number} switchInterval * The minimum amount of time that must pass between switches, in @@ -990,6 +990,39 @@ shaka.extern.AbrConfiguration; shaka.extern.AdvancedAbrConfiguration; +/** + * @typedef {{ + * enabled: boolean, + * useHeaders: boolean, + * sessionId: string, + * contentId: string + * }} + * + * @description + * Common Media Client Data (CMCD) configuration. + * + * @property {boolean} enabled + * If true, enable CMCD data to be sent with media requests. + * Defaults to false. + * @property {boolean} useHeaders + * If true, send CMCD data using the header transmission mode + * instead of query args. Defaults to false. + * @property {string} sessionId + * A GUID identifying the current playback session. A playback session + * typically ties together segments belonging to a single media asset. + * Maximum length is 64 characters. It is RECOMMENDED to conform to the UUID + * specification. By default the sessionId is automatically generated on each + * load() call. + * @property {string} contentId + * A unique string identifying the current content. Maximum length is 64 + * characters. This value is consistent across multiple different sessions and + * devices and is defined and updated at the discretion of the service + * provider. + * @exportDoc + */ +shaka.extern.CmcdConfiguration; + + /** * @typedef {{ * trackSelectionCallback: @@ -1031,6 +1064,7 @@ shaka.extern.OfflineConfiguration; * streaming: shaka.extern.StreamingConfiguration, * abrFactory: shaka.extern.AbrManager.Factory, * abr: shaka.extern.AbrConfiguration, + * cmcd: shaka.extern.CmcdConfiguration, * offline: shaka.extern.OfflineConfiguration, * preferredAudioLanguage: string, * preferredTextLanguage: string, @@ -1057,6 +1091,8 @@ shaka.extern.OfflineConfiguration; * A factory to construct an abr manager. * @property {shaka.extern.AbrConfiguration} abr * ABR configuration and settings. + * @property {shaka.extern.CmcdConfiguration} cmcd + * CMCD configuration and settings. (Common Media Client Data) * @property {shaka.extern.OfflineConfiguration} offline * Offline configuration and settings. * @property {string} preferredAudioLanguage diff --git a/lib/dash/dash_parser.js b/lib/dash/dash_parser.js index 5e0d9a97db..08c76ff8ac 100644 --- a/lib/dash/dash_parser.js +++ b/lib/dash/dash_parser.js @@ -19,6 +19,7 @@ goog.require('shaka.media.PresentationTimeline'); goog.require('shaka.media.SegmentIndex'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.text.TextEngine'); +goog.require('shaka.util.CmcdManager'); goog.require('shaka.util.Error'); goog.require('shaka.util.Functional'); goog.require('shaka.util.Iterables'); @@ -214,6 +215,9 @@ shaka.dash.DashParser = class { this.manifestUris_, this.config_.retryParameters); const networkingEngine = this.playerInterface_.networkingEngine; + const format = shaka.util.CmcdManager.StreamingFormat.DASH; + this.playerInterface_.modifyManifestRequest(request, {format: format}); + const startTime = Date.now(); const operation = networkingEngine.request(requestType, request); this.operationManager_.manage(operation); diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 0c53cb087f..c1f42d41fe 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -25,6 +25,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.CmcdManager'); goog.require('shaka.util.DataViewReader'); goog.require('shaka.util.Error'); goog.require('shaka.util.FakeEvent'); @@ -332,7 +333,8 @@ shaka.hls.HlsParser = class { const segments = await this.createSegments_( streamInfo.verbatimMediaPlaylistUri, playlist, stream.type, stream.mimeType, streamInfo.mediaSequenceToStartTime, mediaVariables, - streamInfo.discontinuityToMediaSequence); + streamInfo.discontinuityToMediaSequence, stream.codecs, + stream.bandwidth); stream.segmentIndex.mergeAndEvict( segments, this.presentationTimeline_.getSegmentAvailabilityStart()); @@ -741,7 +743,7 @@ shaka.hls.HlsParser = class { const videoRange = tag.getAttributeValue('VIDEO-RANGE'); const streamInfos = await this.createStreamInfosForVariantTag_(tag, - resolution, frameRate); + resolution, frameRate, bandwidth); if (streamInfos) { goog.asserts.assert(streamInfos.audio.length || @@ -775,10 +777,11 @@ shaka.hls.HlsParser = class { * @param {!shaka.hls.Tag} tag * @param {?string} resolution * @param {?string} frameRate + * @param {number} bandwidth * @return {!Promise.} * @private */ - async createStreamInfosForVariantTag_(tag, resolution, frameRate) { + async createStreamInfosForVariantTag_(tag, resolution, frameRate, bandwidth) { const ContentType = shaka.util.ManifestParserUtils.ContentType; /** @type {!Array.} */ let allCodecs = this.getCodecsForVariantTag_(tag); @@ -848,7 +851,8 @@ shaka.hls.HlsParser = class { let streamInfo; if (!ignoreStream) { streamInfo = - await this.createStreamInfoFromVariantTag_(tag, allCodecs, type); + await this.createStreamInfoFromVariantTag_(tag, allCodecs, type, + bandwidth); } if (streamInfo) { res[streamInfo.stream.type] = [streamInfo]; @@ -1265,10 +1269,11 @@ shaka.hls.HlsParser = class { * @param {!shaka.hls.Tag} tag * @param {!Array.} allCodecs * @param {string} type + * @param {number} bandwidth * @return {!Promise.} * @private */ - async createStreamInfoFromVariantTag_(tag, allCodecs, type) { + async createStreamInfoFromVariantTag_(tag, allCodecs, type, bandwidth) { goog.asserts.assert(tag.name == 'EXT-X-STREAM-INF', 'Should only be called on variant tags!'); const verbatimMediaPlaylistUri = this.variableSubstitution_( @@ -1284,7 +1289,7 @@ shaka.hls.HlsParser = class { codecs, type, /* language= */ 'und', /* primary= */ false, /* name= */ null, /* channelcount= */ null, closedCaptions, /* characteristics= */ null, /* forced= */ false, - /* spatialAudio= */ false); + /* spatialAudio= */ false, bandwidth); if (streamInfo == null) { return null; } @@ -1311,12 +1316,13 @@ shaka.hls.HlsParser = class { * @param {?string} characteristics * @param {boolean} forced * @param {boolean} spatialAudio + * @param {(number|undefined)} bandwidth * @return {!Promise.} * @private */ async createStreamInfo_(verbatimMediaPlaylistUri, codecs, type, language, primary, name, channelsCount, closedCaptions, characteristics, forced, - spatialAudio) { + spatialAudio, bandwidth = undefined) { // TODO: Refactor, too many parameters let absoluteMediaPlaylistUri = shaka.hls.Utils.constructAbsoluteUri( this.masterPlaylistUri_, verbatimMediaPlaylistUri); @@ -1434,7 +1440,7 @@ shaka.hls.HlsParser = class { try { segments = await this.createSegments_(verbatimMediaPlaylistUri, playlist, type, mimeType, mediaSequenceToStartTime, mediaVariables, - discontinuityToMediaSequence); + discontinuityToMediaSequence, codecs, bandwidth); } catch (error) { if (error.code == shaka.util.Error.Code.HLS_INTERNAL_SKIP_STREAM) { shaka.log.alwaysWarn('Skipping unsupported HLS stream', @@ -1868,11 +1874,14 @@ shaka.hls.HlsParser = class { * @param {!Map.} mediaSequenceToStartTime * @param {!Map.} variables * @param {!Map.} discontinuityToMediaSequence + * @param {string} codecs + * @param {(number|undefined)} bandwidth * @return {!Promise>} * @private */ async createSegments_(verbatimMediaPlaylistUri, playlist, type, mimeType, - mediaSequenceToStartTime, variables, discontinuityToMediaSequence) { + mediaSequenceToStartTime, variables, discontinuityToMediaSequence, + codecs, bandwidth) { /** @type {Array.} */ const hlsSegments = playlist.segments; goog.asserts.assert(hlsSegments.length, 'Playlist should have segments!'); @@ -1915,7 +1924,7 @@ shaka.hls.HlsParser = class { this.playlistStartTime_ = await this.getStartTime_( verbatimMediaPlaylistUri, initSegmentRef, mimeType, position, /* isDiscontinuity= */ false, - hlsSegments[0], variables, type); + hlsSegments[0], variables, type, codecs, bandwidth); } firstStartTime = this.playlistStartTime_; } @@ -1968,7 +1977,8 @@ shaka.hls.HlsParser = class { // eslint-disable-next-line no-await-in-loop timestampOffset = await this.getTimestampOffset_( discontintuitySequenceNum, verbatimMediaPlaylistUri, initSegmentRef, - mimeType, position, item, variables, startTime, type); + mimeType, position, item, variables, startTime, type, codecs, + bandwidth); } // If the stream is low latency and the user has not configured the @@ -2025,13 +2035,16 @@ shaka.hls.HlsParser = class { * @param {!Map.} variables * @param {number} startTime * @param {string} type + * @param {string} codecs + * @param {number|undefined} bandwidth * @return {!Promise.} * @throws {shaka.util.Error} * @private */ async getTimestampOffset_(discontintuitySequenceNum, verbatimMediaPlaylistUri, initSegmentRef, - mimeType, mediaSequenceNumber, segment, variables, startTime, type) { + mimeType, mediaSequenceNumber, segment, variables, startTime, type, + codecs, bandwidth) { let timestampOffset = 0; if (this.discontinuityToTso_.has(discontintuitySequenceNum)) { timestampOffset = @@ -2040,7 +2053,7 @@ shaka.hls.HlsParser = class { const mediaStartTime = await this.getStartTime_( verbatimMediaPlaylistUri, initSegmentRef, mimeType, mediaSequenceNumber, /* isDiscontinuity= */ true, segment, - variables, type); + variables, type, codecs, bandwidth); timestampOffset = startTime - mediaStartTime; shaka.log.v1('Segment timestampOffset =', timestampOffset); this.discontinuityToTso_.set( @@ -2054,10 +2067,14 @@ shaka.hls.HlsParser = class { * segment if we have to. * * @param {!shaka.media.AnySegmentReference} reference + * @param {string} type + * @param {string} mimeType + * @param {string} codecs + * @param {number|undefined} bandwidth * @return {!Promise.} * @private */ - async fetchStartOfSegment_(reference) { + async fetchStartOfSegment_(reference, type, mimeType, codecs, bandwidth) { const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT; // Create two requests: @@ -2071,6 +2088,20 @@ shaka.hls.HlsParser = class { reference.endByte, this.config_.retryParameters); + // We can only add partial CMCD data here because the stream + // and manifest objects are still being created + this.playerInterface_.modifySegmentRequest( + fullRequest, + { + type: type, + init: reference instanceof shaka.media.InitSegmentReference, + duration: reference.endTime - reference.startTime, + mimeType: mimeType, + codecs: codecs, + bandwidth: bandwidth, + }, + ); + if (this.config_.hls.useFullSegmentsForStartTime) { return this.makeNetworkRequest_(fullRequest, requestType); } @@ -2081,6 +2112,18 @@ shaka.hls.HlsParser = class { reference.startByte + shaka.hls.HlsParser.START_OF_SEGMENT_SIZE_ - 1, this.config_.retryParameters); + this.playerInterface_.modifySegmentRequest( + partialRequest, + { + type: type, + init: reference instanceof shaka.media.InitSegmentReference, + duration: reference.endTime - reference.startTime, + mimeType: mimeType, + codecs: codecs, + bandwidth: bandwidth, + }, + ); + // TODO(vaage): The need to do fall back requests is not likely to be unique // to here. It would be nice if the fallback(s) could be included into // the same abortable operation as the original request. @@ -2128,12 +2171,14 @@ shaka.hls.HlsParser = class { * @param {!shaka.hls.Segment} segment * @param {!Map.} variables * @param {string} type + * @param {string} codecs + * @param {number|undefined} bandwidth * @return {!Promise.} * @private */ async getStartTime_( verbatimMediaPlaylistUri, initSegmentRef, mimeType, mediaSequenceNumber, - isDiscontinuity, segment, variables, type) { + isDiscontinuity, segment, variables, type, codecs, bandwidth) { const segmentRef = this.createSegmentReference_( initSegmentRef, /* previousReference= */ null, @@ -2201,10 +2246,22 @@ shaka.hls.HlsParser = class { if (mimeType == 'video/mp4' || mimeType == 'audio/mp4') { // We also need the init segment to get the correct timescale. But if the // stream is self-initializing, use the same response for both. - const fetches = [this.fetchStartOfSegment_(segmentRef)]; + const fetches = [this.fetchStartOfSegment_( + segmentRef, + type, + mimeType, + codecs, + bandwidth, + )]; if (initSegmentRef) { - fetches.push(this.fetchStartOfSegment_(initSegmentRef)); + fetches.push(this.fetchStartOfSegment_( + initSegmentRef, + type, + mimeType, + codecs, + bandwidth, + )); } const responses = await Promise.all(fetches); @@ -2220,7 +2277,13 @@ shaka.hls.HlsParser = class { } if (mimeType == 'video/mp2t') { - const response = await this.fetchStartOfSegment_(segmentRef); + const response = await this.fetchStartOfSegment_( + segmentRef, + type, + mimeType, + codecs, + bandwidth, + ); goog.asserts.assert(response.data, 'Should have a response body!'); return this.getStartTimeFromTsSegment_( verbatimMediaPlaylistUri, response.uri, response.data); @@ -2598,6 +2661,9 @@ shaka.hls.HlsParser = class { const request = shaka.net.NetworkingEngine.makeRequest( [absoluteUri], this.config_.retryParameters); + const format = shaka.util.CmcdManager.StreamingFormat.HLS; + this.playerInterface_.modifyManifestRequest(request, {format: format}); + return this.makeNetworkRequest_(request, requestType); } diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index 1a7e3ee9b7..87f81d0943 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -1777,6 +1777,19 @@ shaka.media.StreamingEngine = class { shaka.log.v2('fetching: reference=', reference); + const stream = mediaState.stream; + this.playerInterface_.modifySegmentRequest( + request, + { + type: stream.type, + init: reference instanceof shaka.media.InitSegmentReference, + duration: reference.endTime - reference.startTime, + mimeType: stream.mimeType, + codecs: stream.codecs, + bandwidth: stream.bandwidth, + }, + ); + const op = this.playerInterface_.netEngine.request(requestType, request); mediaState.operation = op; const response = await op.promise; @@ -1940,6 +1953,8 @@ shaka.media.StreamingEngine = class { * @typedef {{ * getPresentationTime: function():number, * getBandwidthEstimate: function():number, + * modifySegmentRequest: function(shaka.extern.Request, + * shaka.util.CmcdManager.SegmentInfo), * mediaSourceEngine: !shaka.media.MediaSourceEngine, * netEngine: shaka.net.NetworkingEngine, * onError: function(!shaka.util.Error), @@ -1953,6 +1968,9 @@ shaka.media.StreamingEngine = class { * viewer is seeing on screen right now. * @property {function():number} getBandwidthEstimate * Get the estimated bandwidth in bits per second. + * @property {function(shaka.extern.Request, + * shaka.extern.Cmcd.SegmentInfo)} modifySegmentRequest + * The request modifier * @property {!shaka.media.MediaSourceEngine} mediaSourceEngine * The MediaSourceEngine. The caller retains ownership. * @property {shaka.net.NetworkingEngine} netEngine diff --git a/lib/offline/storage.js b/lib/offline/storage.js index 93ec2fcd27..c8b3708d26 100644 --- a/lib/offline/storage.js +++ b/lib/offline/storage.js @@ -1180,6 +1180,10 @@ shaka.offline.Storage = class { const playerInterface = { networkingEngine: networkingEngine, + // No need to apply CMCD data for offline requests + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, + // Don't bother filtering now. We will do that later when we have all the // information we need to filter. filter: () => Promise.resolve(), diff --git a/lib/player.js b/lib/player.js index e15b6d9fd7..72a9603d67 100644 --- a/lib/player.js +++ b/lib/player.js @@ -36,6 +36,7 @@ goog.require('shaka.text.UITextDisplayer'); goog.require('shaka.text.WebVttGenerator'); goog.require('shaka.util.AbortableOperation'); goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.CmcdManager'); goog.require('shaka.util.ConfigUtils'); goog.require('shaka.util.Error'); goog.require('shaka.util.EventManager'); @@ -467,6 +468,9 @@ shaka.Player = class extends shaka.util.FakeEventTarget { /** @private {shaka.media.RegionTimeline} */ this.regionTimeline_ = null; + /** @private {shaka.util.CmcdManager} */ + this.cmcdManager_ = null; + /** @private {shaka.media.StreamingEngine} */ this.streamingEngine_ = null; @@ -758,6 +762,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.config_ = null; this.stats_ = null; this.videoContainer_ = null; + this.cmcdManager_ = null; if (this.networkingEngine_) { await this.networkingEngine_.destroy(); @@ -1143,6 +1148,9 @@ shaka.Player = class extends shaka.util.FakeEventTarget { // before we allow calls to |updateStateHistory|. this.stats_ = new shaka.util.Stats(); + // Create the CMCD manager so client data can be attached to all requests + this.cmcdManager_ = this.createCmcd_(); + // Load's request is a little different, so we can't use our normal // listeners-to-promise method. It is the only request where we may skip the // request, so we need to set the on skip callback to reject with a specific @@ -1644,6 +1652,9 @@ shaka.Player = class extends shaka.util.FakeEventTarget { goog.asserts.assert( this.networkingEngine_, 'Need networking engine to parse manifest.'); + goog.asserts.assert( + this.cmcdManager_, + 'Need CMCD manager to populate manifest request data.'); goog.asserts.assert( this.config_, 'Need player config to parse manifest.'); @@ -1670,6 +1681,12 @@ shaka.Player = class extends shaka.util.FakeEventTarget { const playerInterface = { networkingEngine: networkingEngine, + modifyManifestRequest: (request, manifestInfo) => { + this.cmcdManager_.applyManifestData(request, manifestInfo); + }, + modifySegmentRequest: (request, segmentInfo) => { + this.cmcdManager_.applySegmentData(request, segmentInfo); + }, filter: (manifest) => this.filterManifest_(manifest), makeTextStreamsForClosedCaptions: (manifest) => { return this.makeTextStreamsForClosedCaptions_(manifest); @@ -2234,9 +2251,13 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.video_.textTracks, 'change', () => this.onTracksChanged_()); } + const extension = shaka.media.ManifestParser.getExtension(has.uri); + const mimeType = + shaka.Player.SRC_EQUAL_EXTENSIONS_TO_MIME_TYPES_[extension]; + // By setting |src| we are done "loading" with src=. We don't need to set // the current time because |playhead| will do that for us. - has.mediaElement.src = has.uri; + has.mediaElement.src = this.cmcdManager_.appendSrcData(has.uri, mimeType); // Tizen 3 / WebOS won't load anything unless you call load() explicitly, // no matter the value of the preload attribute. This is harmful on some @@ -2764,6 +2785,27 @@ shaka.Player = class extends shaka.util.FakeEventTarget { mediaElement, closedCaptionsParser, textDisplayer, onMetadata); } + /** + * Create a new CMCD manager. + * + * @private + */ + createCmcd_() { + const abr = this.abrManager_; + + /** @type {shaka.util.CmcdManager.PlayerInterface} */ + const playerInterface = { + getBandwidthEstimate: () => abr ? abr.getBandwidthEstimate() : NaN, + getBufferedInfo: () => this.getBufferedInfo(), + getCurrentTime: () => this.video_ ? this.video_.currentTime : 0, + getManifest: () => this.manifest_, + getPlaybackRate: () => this.getPlaybackRate(), + isLive: () => this.isLive(), + }; + + return new shaka.util.CmcdManager(playerInterface, this.config_.cmcd); + } + /** * Creates a new instance of StreamingEngine. This can be replaced by tests * to create fake instances instead. @@ -2773,13 +2815,16 @@ shaka.Player = class extends shaka.util.FakeEventTarget { createStreamingEngine() { goog.asserts.assert( this.playhead_ && this.abrManager_ && this.mediaSourceEngine_ && - this.manifest_, + this.cmcdManager_ && this.manifest_, 'Must not be destroyed'); /** @type {shaka.media.StreamingEngine.PlayerInterface} */ const playerInterface = { getPresentationTime: () => this.playhead_.getTime(), getBandwidthEstimate: () => this.abrManager_.getBandwidthEstimate(), + modifySegmentRequest: (request, segmentInfo) => { + this.cmcdManager_.applySegmentData(request, segmentInfo); + }, mediaSourceEngine: this.mediaSourceEngine_, netEngine: this.networkingEngine_, onError: (error) => this.onError_(error), @@ -4349,7 +4394,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { } const trackElement = /** @type {!HTMLTrackElement} */(document.createElement('track')); - trackElement.src = uri; + trackElement.src = this.cmcdManager_.appendTextTrackData(uri); trackElement.label = label || ''; trackElement.kind = kind; trackElement.srclang = language; @@ -4687,7 +4732,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { } const trackElement = /** @type {!HTMLTrackElement} */(document.createElement('track')); - trackElement.src = uri; + trackElement.src = this.cmcdManager_.appendTextTrackData(uri); trackElement.label = label; trackElement.kind = kind; trackElement.srclang = language; @@ -4716,6 +4761,8 @@ shaka.Player = class extends shaka.util.FakeEventTarget { const request = shaka.net.NetworkingEngine.makeRequest([uri], retryParams); request.method = 'GET'; + this.cmcdManager_.applyTextData(request); + const response = await netEngine.request(type, request).promise; return response.data; @@ -5113,6 +5160,9 @@ shaka.Player = class extends shaka.util.FakeEventTarget { if (loaded) { this.playRateController_.setBuffering(isBuffering); + if (this.cmcdManager_) { + this.cmcdManager_.setBuffering(isBuffering); + } this.updateStateHistory_(); } diff --git a/lib/util/cmcd_manager.js b/lib/util/cmcd_manager.js new file mode 100644 index 0000000000..d8630eb621 --- /dev/null +++ b/lib/util/cmcd_manager.js @@ -0,0 +1,822 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.util.CmcdManager'); + +goog.require('shaka.log'); + + +/** + * @summary + * A CmcdManager maintains CMCD state as well as a collection of utility + * functions. + */ +shaka.util.CmcdManager = class { + /** + * @param {shaka.util.CmcdManager.PlayerInterface} playerInterface + * @param {shaka.extern.CmcdConfiguration} config + */ + constructor(playerInterface, config) { + /** @private {shaka.util.CmcdManager.PlayerInterface} */ + this.playerInterface_ = playerInterface; + + /** @private {?shaka.extern.CmcdConfiguration} */ + this.config_ = config; + + /** + * Session ID + * + * @private {string} + */ + this.sid_ = config.sessionId || window.crypto.randomUUID(); + + /** + * Streaming format + * + * @private {(shaka.util.CmcdManager.StreamingFormat|undefined)} + */ + this.sf_ = undefined; + + /** + * @private {boolean} + */ + this.playbackStarted_ = false; + + /** + * @private {boolean} + */ + this.buffering_ = true; + + /** + * @private {boolean} + */ + this.starved_ = false; + } + + /** + * Set the buffering state + * + * @param {boolean} buffering + */ + setBuffering(buffering) { + if (!buffering && !this.playbackStarted_) { + this.playbackStarted_ = true; + } + + if (this.playbackStarted_ && buffering) { + this.starved_ = true; + } + + this.buffering_ = buffering; + } + + /** + * Apply CMCD data to a manifest request. + * + * @param {!shaka.extern.Request} request + * The request to apply CMCD data to + * @param {shaka.util.CmcdManager.ManifestInfo} manifestInfo + * The manifest format + */ + applyManifestData(request, manifestInfo) { + try { + if (!this.config_.enabled) { + return; + } + + this.sf_ = manifestInfo.format; + + this.apply_(request, { + ot: shaka.util.CmcdManager.ObjectType.MANIFEST, + su: !this.playbackStarted_, + }); + } catch (error) { + shaka.log.warnOnce('CMCD_MANIFEST_ERROR', + 'Could not generate manifest CMCD data.', error); + } + } + + /** + * Apply CMCD data to a segment request + * + * @param {!shaka.extern.Request} request + * @param {shaka.util.CmcdManager.SegmentInfo} segmentInfo + */ + applySegmentData(request, segmentInfo) { + try { + if (!this.config_.enabled) { + return; + } + + const data = { + d: segmentInfo.duration * 1000, + st: this.getStreamType_(), + }; + + data.ot = this.getObjectType_(segmentInfo); + + const isMedia = data.ot === shaka.util.CmcdManager.ObjectType.VIDEO || + data.ot === shaka.util.CmcdManager.ObjectType.AUDIO || + data.ot === shaka.util.CmcdManager.ObjectType.MUXED || + data.ot === shaka.util.CmcdManager.ObjectType.TIMED_TEXT; + + if (isMedia) { + data.bl = this.getBufferLength_(segmentInfo.type); + } + + if (segmentInfo.bandwidth) { + data.br = segmentInfo.bandwidth / 1000; + } + + data.tb = this.getTopBandwidth_(segmentInfo.type) / 1000; + + this.apply_(request, data); + } catch (error) { + shaka.log.warnOnce('CMCD_SEGMENT_ERROR', + 'Could not generate segment CMCD data.', error); + } + } + + /** + * Apply CMCD data to a text request + * + * @param {!shaka.extern.Request} request + */ + applyTextData(request) { + try { + this.apply_(request, { + ot: shaka.util.CmcdManager.ObjectType.CAPTION, + su: true, + }); + } catch (error) { + shaka.log.warnOnce('CMCD_TEXT_ERROR', + 'Could not generate text CMCD data.', error); + } + } + + /** + * Apply CMCD data to streams loaded via src=. + * + * @param {string} uri + * @param {string} mimeType + * @return {string} + */ + appendSrcData(uri, mimeType) { + try { + if (!this.config_.enabled) { + return uri; + } + + const data = this.createData_(); + data.ot = this.getObjectTypeFromMimeType_(mimeType); + data.su = true; + + const query = shaka.util.CmcdManager.toQuery(data); + + return shaka.util.CmcdManager.appendQueryToUri(uri, query); + } catch (error) { + shaka.log.warnOnce('CMCD_SRC_ERROR', + 'Could not generate src CMCD data.', error); + return uri; + } + } + + /** + * Apply CMCD data to side car text track uri. + * + * @param {string} uri + * @return {string} + */ + appendTextTrackData(uri) { + try { + if (!this.config_.enabled) { + return uri; + } + + const data = this.createData_(); + data.ot = shaka.util.CmcdManager.ObjectType.CAPTION; + data.su = true; + + const query = shaka.util.CmcdManager.toQuery(data); + + return shaka.util.CmcdManager.appendQueryToUri(uri, query); + } catch (error) { + shaka.log.warnOnce('CMCD_TEXT_TRACK_ERROR', + 'Could not generate text track CMCD data.', error); + return uri; + } + } + + /** + * Create baseline CMCD data + * + * @return {shaka.util.CmcdManager.Data} + * @private + */ + createData_() { + return { + v: shaka.util.CmcdManager.Version, + sf: this.sf_, + sid: this.sid_, + cid: this.config_.contentId, + mtp: this.playerInterface_.getBandwidthEstimate() / 1000, + }; + } + + /** + * Apply CMCD data to a request. + * + * @param {!shaka.extern.Request} request The request to apply CMCD data to + * @param {!shaka.util.CmcdManager.Data} data The data object + * @param {boolean} useHeaders Send data via request headers + * @private + */ + apply_(request, data = {}, useHeaders = this.config_.useHeaders) { + if (!this.config_.enabled) { + return; + } + + // apply baseline data + Object.assign(data, this.createData_()); + + data.pr = this.playerInterface_.getPlaybackRate(); + + const isVideo = data.ot === shaka.util.CmcdManager.ObjectType.VIDEO || + data.ot === shaka.util.CmcdManager.ObjectType.MUXED; + + if (this.starved_ && isVideo) { + data.bs = true; + data.su = true; + this.starved_ = false; + } + + if (data.su == null) { + data.su = this.buffering_; + } + + // TODO: Implement rtp, nrr, nor, dl + + if (useHeaders) { + const headers = shaka.util.CmcdManager.toHeaders(data); + if (!Object.keys(headers).length) { + return; + } + + Object.assign(request.headers, headers); + } else { + const query = shaka.util.CmcdManager.toQuery(data); + if (!query) { + return; + } + + request.uris = request.uris.map((uri) => { + return shaka.util.CmcdManager.appendQueryToUri(uri, query); + }); + } + } + + /** + * The CMCD object type. + * + * @param {shaka.util.CmcdManager.SegmentInfo} segmentInfo + * @private + */ + getObjectType_(segmentInfo) { + const type = segmentInfo.type; + + if (segmentInfo.init) { + return shaka.util.CmcdManager.ObjectType.INIT; + } + + if (type == 'video') { + if (segmentInfo.codecs.includes(',')) { + return shaka.util.CmcdManager.ObjectType.MUXED; + } + return shaka.util.CmcdManager.ObjectType.VIDEO; + } + + if (type == 'audio') { + return shaka.util.CmcdManager.ObjectType.AUDIO; + } + + if (type == 'text') { + if (segmentInfo.mimeType === 'application/mp4') { + return shaka.util.CmcdManager.ObjectType.TIMED_TEXT; + } + return shaka.util.CmcdManager.ObjectType.CAPTION; + } + + return undefined; + } + + /** + * The CMCD object type from mimeType. + * + * @param {!string} mimeType + * @return {(shaka.util.CmcdManager.ObjectType|undefined)} + * @private + */ + getObjectTypeFromMimeType_(mimeType) { + switch (mimeType) { + case 'video/webm': + case 'video/mp4': + return shaka.util.CmcdManager.ObjectType.MUXED; + + case 'application/x-mpegurl': + return shaka.util.CmcdManager.ObjectType.MANIFEST; + + default: + return undefined; + } + } + + /** + * Get the buffer length for a media type in milliseconds + * + * @param {string} type + * @return {number} + * @private + */ + getBufferLength_(type) { + const ranges = this.playerInterface_.getBufferedInfo()[type]; + + if (!ranges.length) { + return NaN; + } + + const start = this.playerInterface_.getCurrentTime(); + const range = ranges.find((r) => r.start <= start && r.end >= start); + + if (!range) { + return NaN; + } + + return (range.end - start) * 1000; + } + + /** + * Get the stream type + * + * @return {shaka.util.CmcdManager.StreamType} + * @private + */ + getStreamType_() { + const isLive = this.playerInterface_.isLive(); + if (isLive) { + return shaka.util.CmcdManager.StreamType.LIVE; + } else { + return shaka.util.CmcdManager.StreamType.VOD; + } + } + + /** + * Get the highest bandwidth for a given type. + * + * @param {string} type + * @return {number} + * @private + */ + getTopBandwidth_(type) { + const manifest = this.playerInterface_.getManifest(); + if (!manifest) { + return NaN; + } + + const variants = (type === 'text') ? + manifest.textStreams : manifest.variants; + let top = variants[0][type] || variants[0]; + + for (const variant of variants) { + const stream = variant[type] || variant; + if (stream.bandwidth > top.bandwidth) { + top = stream; + } + } + + return top.bandwidth; + } + + /** + * Serialize a CMCD data object according to the rules defined in the + * section 3.2 of + * [CTA-5004](https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf). + * + * @param {shaka.util.CmcdManager.Data} data The CMCD data object + * @return {string} + */ + static serialize(data) { + const results = []; + const isValid = (value) => + !Number.isNaN(value) && value != null && value !== '' && value !== false; + const toRounded = (value) => Math.round(value); + const toHundred = (value) => toRounded(value / 100) * 100; + const toUrlSafe = (value) => encodeURIComponent(value); + const formatters = { + br: toRounded, + d: toRounded, + bl: toHundred, + dl: toHundred, + mtp: toHundred, + nor: toUrlSafe, + rtp: toHundred, + tb: toRounded, + }; + + const keys = Object.keys(data || {}).sort(); + + for (const key of keys) { + let value = data[key]; + + // ignore invalid values + if (!isValid(value)) { + continue; + } + + // Version should only be reported if not equal to 1. + if (key === 'v' && value === 1) { + continue; + } + + // Playback rate should only be sent if not equal to 1. + if (key == 'pr' && value === 1) { + continue; + } + + // Certain values require special formatting + const formatter = formatters[key]; + if (formatter) { + value = formatter(value); + } + + // Serialize the key/value pair + const type = typeof value; + let result; + + if (type === 'string' && key !== 'ot' && key !== 'sf' && key !== 'st') { + result = `${key}="${value.replace(/"/g, '"')}"`; + } else if (type === 'boolean') { + result = key; + } else if (type === 'symbol') { + result = `${key}=${value.description}`; + } else { + result = `${key}=${value}`; + } + + results.push(result); + } + + return results.join(','); + } + + /** + * Convert a CMCD data object to request headers according to the rules + * defined in the section 2.1 and 3.2 of + * [CTA-5004](https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf). + * + * @param {shaka.util.CmcdManager.Data} data The CMCD data object + * @return {!Object} + */ + static toHeaders(data) { + const keys = Object.keys(data); + const headers = {}; + const headerNames = ['Object', 'Request', 'Session', 'Status']; + const headerGroups = [{}, {}, {}, {}]; + const headerMap = { + br: 0, d: 0, ot: 0, tb: 0, + bl: 1, dl: 1, mtp: 1, nor: 1, nrr: 1, su: 1, + cid: 2, pr: 2, sf: 2, sid: 2, st: 2, v: 2, + bs: 3, rtp: 3, + }; + + for (const key of keys) { + // Unmapped fields are mapped to the Request header + const index = (headerMap[key] != null) ? headerMap[key] : 1; + headerGroups[index][key] = data[key]; + } + + for (let i = 0; i < headerGroups.length; i++) { + const value = shaka.util.CmcdManager.serialize(headerGroups[i]); + if (value) { + headers[`CMCD-${headerNames[i]}`] = value; + } + } + + return headers; + } + + /** + * Convert a CMCD data object to query args according to the rules + * defined in the section 2.2 and 3.2 of + * [CTA-5004](https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf). + * + * @param {shaka.util.CmcdManager.Data} data The CMCD data object + * @return {string} + */ + static toQuery(data) { + return `CMCD=${encodeURIComponent(shaka.util.CmcdManager.serialize(data))}`; + } + + /** + * Append query args to a uri. + * + * @param {string} uri + * @param {string} query + * @return {string} + */ + static appendQueryToUri(uri, query) { + if (!query) { + return uri; + } + + const separator = uri.includes('?') ? '&' : '?'; + return `${uri}${separator}${query}`; + } +}; + + +/** + * @typedef {{ + * getBandwidthEstimate: function():number, + * getBufferedInfo: function():shaka.extern.BufferedInfo, + * getCurrentTime: function():number, + * getManifest: function():shaka.extern.Manifest, + * getPlaybackRate: function():number, + * isLive: function():boolean + * }} + * + * @property {function():number} getBandwidthEstimate + * Get the estimated bandwidth in bits per second. + * @property {function():shaka.extern.BufferedInfo} getBufferedInfo + * Get information about what the player has buffered. + * @property {function():number} getCurrentTime + * Get the current time + * @property {function():shaka.extern.Manifest} getManifest + * Get the manifest + * @property {function():number} getPlaybackRate + * Get the playback rate + * @property {function():boolean} isLive + * Get if the player is playing live content. + */ +shaka.util.CmcdManager.PlayerInterface; + + +/** + * @typedef {{ + * type: string, + * init: boolean, + * duration: number, + * mimeType: string, + * codecs: string, + * bandwidth: (number|undefined) + * }} + * + * @property {string} type + * The media type + * @property {boolean} init + * Flag indicating whether the segment is an init segment + * @property {number} duration + * The duration of the segment in seconds + * @property {string} mimeType + * The segment's mime type + * @property {string} codecs + * The segment's codecs + * @property {(number|undefined)} bandwidth + * The segment's variation bandwidth + * + * @export + */ +shaka.util.CmcdManager.SegmentInfo; + + +/** + * @typedef {{ + * format: shaka.util.CmcdManager.StreamingFormat + * }} + * + * @property {shaka.util.CmcdManager.StreamingFormat} format + * The manifest's stream format + * + * @export + */ +shaka.util.CmcdManager.ManifestInfo; + + +/** + * @typedef {{ + * br: (number|undefined), + * d: (number|undefined), + * ot: (shaka.util.CmcdManager.ObjectType|undefined), + * tb: (number|undefined), + * bl: (number|undefined), + * dl: (number|undefined), + * mtp: (number|undefined), + * nor: (string|undefined), + * nrr: (string|undefined), + * su: (boolean|undefined), + * cid: (string|undefined), + * pr: (number|undefined), + * sf: (shaka.util.CmcdManager.StreamingFormat|undefined), + * sid: (string|undefined), + * st: (shaka.util.CmcdManager.StreamType|undefined), + * v: (number|undefined), + * bs: (boolean|undefined), + * rtp: (number|undefined) + * }} + * + * @description + * Client Media Common Data (CMCD) data. + * + * @property {number} br + * The encoded bitrate of the audio or video object being requested. This may + * not be known precisely by the player; however, it MAY be estimated based + * upon playlist/manifest declarations. If the playlist declares both peak and + * average bitrate values, the peak value should be transmitted. + * + * @property {number} d + * The playback duration in milliseconds of the object being requested. If a + * partial segment is being requested, then this value MUST indicate the + * playback duration of that part and not that of its parent segment. This + * value can be an approximation of the estimated duration if the explicit + * value is not known. + * + * @property {shaka.util.CmcdManager.ObjectType} ot + * The media type of the current object being requested: + * - `m` = text file, such as a manifest or playlist + * - `a` = audio only + * - `v` = video only + * - `av` = muxed audio and video + * - `i` = init segment + * - `c` = caption or subtitle + * - `tt` = ISOBMFF timed text track + * - `k` = cryptographic key, license or certificate. + * - `o` = other + * + * If the object type being requested is unknown, then this key MUST NOT be + * used. + * + * @property {number} tb + * The highest bitrate rendition in the manifest or playlist that the client + * is allowed to play, given current codec, licensing and sizing constraints. + * + * @property {number} bl + * The buffer length associated with the media object being requested. This + * value MUST be rounded to the nearest 100 ms. This key SHOULD only be sent + * with an object type of ‘a’, ‘v’ or ‘av’. + * + * @property {number} dl + * Deadline from the request time until the first sample of this + * Segment/Object needs to be available in order to not create a buffer + * underrun or any other playback problems. This value MUST be rounded to the + * nearest 100ms. For a playback rate of 1, this may be equivalent to the + * player’s remaining buffer length. + * + * @property {number} mtp + * The throughput between client and server, as measured by the client and + * MUST be rounded to the nearest 100 kbps. This value, however derived, + * SHOULD be the value that the client is using to make its next Adaptive + * Bitrate switching decision. If the client is connected to multiple + * servers concurrently, it must take care to report only the throughput + * measured against the receiving server. If the client has multiple + * concurrent connections to the server, then the intent is that this value + * communicates the aggregate throughput the client sees across all those + * connections. + * + * @property {string} nor + * Relative path of the next object to be requested. This can be used to + * trigger pre-fetching by the CDN. This MUST be a path relative to the + * current request. This string MUST be URLEncoded. The client SHOULD NOT + * depend upon any pre-fetch action being taken - it is merely a request for + * such a pre-fetch to take place. + * + * @property {string} nrr + * If the next request will be a partial object request, then this string + * denotes the byte range to be requested. If the ‘nor’ field is not set, then + * the object is assumed to match the object currently being requested. The + * client SHOULD NOT depend upon any pre-fetch action being taken – it is + * merely a request for such a pre-fetch to take place. Formatting is similar + * to the HTTP Range header, except that the unit MUST be ‘byte’, the ‘Range:’ + * prefix is NOT required and specifying multiple ranges is NOT allowed. Valid + * combinations are: + * + * - `"\-"` + * - `"\-\"` + * - `"-\"` + * + * @property {boolean} su + * Key is included without a value if the object is needed urgently due to + * startup, seeking or recovery after a buffer-empty event. The media SHOULD + * not be rendering when this request is made. This key MUST not be sent if it + * is FALSE. + * + * @property {string} cid + * A unique string identifying the current content. Maximum length is 64 + * characters. This value is consistent across multiple different sessions and + * devices and is defined and updated at the discretion of the service + * provider. + * + * @property {number} pr + * The playback rate. `1` if real-time, `2` if double speed, `0` if not + * playing. SHOULD only be sent if not equal to `1`. + * + * @property {shaka.util.CmcdManager.StreamingFormat} sf + * The streaming format that defines the current request. + * + * - `d` = MPEG DASH + * - `h` = HTTP Live Streaming (HLS) + * - `s` = Smooth Streaming + * - `o` = other + * + * If the streaming format being requested is unknown, then this key MUST NOT + * be used. + * + * @property {string} sid + * A GUID identifying the current playback session. A playback session + * typically ties together segments belonging to a single media asset. Maximum + * length is 64 characters. It is RECOMMENDED to conform to the UUID + * specification. + * + * @property {shaka.util.CmcdManager.StreamType} st + * Stream type + * - `v` = all segments are available – e.g., VOD + * - `l` = segments become available over time – e.g., LIVE + * + * @property {number} v + * The version of this specification used for interpreting the defined key + * names and values. If this key is omitted, the client and server MUST + * interpret the values as being defined by version 1. Client SHOULD omit this + * field if the version is 1. + * + * @property {boolean} bs + * Buffer starvation key is included without a value if the buffer was starved + * at some point between the prior request and this object request, resulting + * in the player being in a rebuffering state and the video or audio playback + * being stalled. This key MUST NOT be sent if the buffer was not starved + * since the prior request. + * + * If the object type `ot` key is sent along with this key, then the `bs` key + * refers to the buffer associated with the particular object type. If no + * object type is communicated, then the buffer state applies to the current + * session. + * + * @property {number} rtp + * Requested maximum throughput + * + * The requested maximum throughput that the client considers sufficient for + * delivery of the asset. Values MUST be rounded to the nearest 100kbps. For + * example, a client would indicate that the current segment, encoded at + * 2Mbps, is to be delivered at no more than 10Mbps, by using rtp=10000. + * + * Note: This can benefit clients by preventing buffer saturation through + * over-delivery and can also deliver a community benefit through fair-share + * delivery. The concept is that each client receives the throughput necessary + * for great performance, but no more. The CDN may not support the rtp + * feature. + */ +shaka.util.CmcdManager.Data; + + +/** + * @enum {string} + */ +shaka.util.CmcdManager.ObjectType = { + MANIFEST: 'm', + AUDIO: 'a', + VIDEO: 'v', + MUXED: 'av', + INIT: 'i', + CAPTION: 'c', + TIMED_TEXT: 'tt', + KEY: 'k', + OTHER: 'o', +}; + + +/** + * @enum {string} + */ +shaka.util.CmcdManager.StreamType = { + VOD: 'v', + LIVE: 'l', +}; + + +/** + * @enum {string} + * @export + */ +shaka.util.CmcdManager.StreamingFormat = { + DASH: 'd', + HLS: 'h', + SMOOTH: 's', + OTHER: 'o', +}; + + +/** + * The CMCD spec version + * @const {number} + */ +shaka.util.CmcdManager.Version = 1; diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index 571f41280c..3851e64b14 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -246,6 +246,13 @@ shaka.util.PlayerConfiguration = class { }, }; + const cmcd = { + enabled: false, + sessionId: '', + contentId: '', + useHeaders: false, + }; + /** @type {shaka.extern.PlayerConfiguration} */ const config = { drm: drm, @@ -278,6 +285,7 @@ shaka.util.PlayerConfiguration = class { playRangeStart: 0, playRangeEnd: Infinity, textDisplayFactory: () => null, + cmcd: cmcd, }; // Add this callback so that we can reference the preferred audio language diff --git a/test/dash/dash_parser_content_protection_unit.js b/test/dash/dash_parser_content_protection_unit.js index 31e06fab1e..f9a046bb9a 100644 --- a/test/dash/dash_parser_content_protection_unit.js +++ b/test/dash/dash_parser_content_protection_unit.js @@ -42,6 +42,8 @@ describe('DashParser ContentProtection', () => { const playerInterface = { networkingEngine: netEngine, + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, filter: (manifest) => Promise.resolve(), makeTextStreamsForClosedCaptions: (manifest) => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. diff --git a/test/dash/dash_parser_live_unit.js b/test/dash/dash_parser_live_unit.js index 25806a7723..4927040612 100644 --- a/test/dash/dash_parser_live_unit.js +++ b/test/dash/dash_parser_live_unit.js @@ -39,6 +39,8 @@ describe('DashParser Live', () => { parser.configure(shaka.util.PlayerConfiguration.createDefault().manifest); playerInterface = { networkingEngine: fakeNetEngine, + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, filter: (manifest) => Promise.resolve(), makeTextStreamsForClosedCaptions: (manifest) => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. diff --git a/test/dash/dash_parser_manifest_unit.js b/test/dash/dash_parser_manifest_unit.js index 017226d962..07e7c0e353 100644 --- a/test/dash/dash_parser_manifest_unit.js +++ b/test/dash/dash_parser_manifest_unit.js @@ -49,6 +49,8 @@ describe('DashParser Manifest', () => { onEventSpy = jasmine.createSpy('onEvent'); playerInterface = { networkingEngine: fakeNetEngine, + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, filter: (manifest) => Promise.resolve(), makeTextStreamsForClosedCaptions: (manifest) => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. diff --git a/test/dash/dash_parser_segment_base_unit.js b/test/dash/dash_parser_segment_base_unit.js index ca98d7ed88..9e07303309 100644 --- a/test/dash/dash_parser_segment_base_unit.js +++ b/test/dash/dash_parser_segment_base_unit.js @@ -35,6 +35,8 @@ describe('DashParser SegmentBase', () => { playerInterface = { networkingEngine: fakeNetEngine, + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, filter: (manifest) => Promise.resolve(), makeTextStreamsForClosedCaptions: (manifest) => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. diff --git a/test/dash/dash_parser_segment_list_unit.js b/test/dash/dash_parser_segment_list_unit.js index 7f8ce1a6e9..1c8015478b 100644 --- a/test/dash/dash_parser_segment_list_unit.js +++ b/test/dash/dash_parser_segment_list_unit.js @@ -344,6 +344,8 @@ describe('DashParser SegmentList', () => { const playerInterface = { networkingEngine: networkingEngine, + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, filter: () => {}, makeTextStreamsForClosedCaptions: (manifest) => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. @@ -374,4 +376,3 @@ describe('DashParser SegmentList', () => { expect(actual).toEqual(expected); }); }); - diff --git a/test/dash/dash_parser_segment_template_unit.js b/test/dash/dash_parser_segment_template_unit.js index 8af8134b76..9e70452991 100644 --- a/test/dash/dash_parser_segment_template_unit.js +++ b/test/dash/dash_parser_segment_template_unit.js @@ -46,6 +46,8 @@ describe('DashParser SegmentTemplate', () => { playerInterface = { networkingEngine: fakeNetEngine, + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, filter: (manifest) => Promise.resolve(), makeTextStreamsForClosedCaptions: (manifest) => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. @@ -585,4 +587,3 @@ describe('DashParser SegmentTemplate', () => { }); }); }); - diff --git a/test/hls/hls_live_unit.js b/test/hls/hls_live_unit.js index c3487fd9e7..0595ba55d9 100644 --- a/test/hls/hls_live_unit.js +++ b/test/hls/hls_live_unit.js @@ -116,6 +116,8 @@ describe('HlsParser live', () => { config = shaka.util.PlayerConfiguration.createDefault().manifest; playerInterface = { + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, filter: () => Promise.resolve(), makeTextStreamsForClosedCaptions: (manifest) => {}, networkingEngine: fakeNetEngine, diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index a693134891..7ee5d21f12 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -94,6 +94,8 @@ describe('HlsParser', () => { config = shaka.util.PlayerConfiguration.createDefault().manifest; onEventSpy = jasmine.createSpy('onEvent'); playerInterface = { + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, filter: () => Promise.resolve(), makeTextStreamsForClosedCaptions: (manifest) => {}, networkingEngine: fakeNetEngine, diff --git a/test/media/streaming_engine_integration.js b/test/media/streaming_engine_integration.js index 709175711b..1b48fad0ba 100644 --- a/test/media/streaming_engine_integration.js +++ b/test/media/streaming_engine_integration.js @@ -60,7 +60,6 @@ describe('StreamingEngine', () => { /** @type {!shaka.media.StreamingEngine} */ let streamingEngine; - /** @type {shaka.extern.Variant} */ let variant; @@ -262,6 +261,7 @@ describe('StreamingEngine', () => { function createStreamingEngine() { const playerInterface = { + modifySegmentRequest: (request, segmentInfo) => {}, getPresentationTime: () => playhead.getTime(), getBandwidthEstimate: () => 1e6, mediaSourceEngine: mediaSourceEngine, diff --git a/test/media/streaming_engine_unit.js b/test/media/streaming_engine_unit.js index 9d9128fcd9..2bb312ccad 100644 --- a/test/media/streaming_engine_unit.js +++ b/test/media/streaming_engine_unit.js @@ -451,6 +451,7 @@ describe('StreamingEngine', () => { presentationTimeInSeconds != undefined, 'All tests should have defined an initial presentation time by now!'); const playerInterface = { + modifySegmentRequest: (request, segmentInfo) => {}, getPresentationTime: () => presentationTimeInSeconds, getBandwidthEstimate: Util.spyFunc(getBandwidthEstimate), mediaSourceEngine: mediaSourceEngine, diff --git a/test/test/util/dash_parser_util.js b/test/test/util/dash_parser_util.js index cffe94785b..7247aea971 100644 --- a/test/test/util/dash_parser_util.js +++ b/test/test/util/dash_parser_util.js @@ -46,6 +46,8 @@ shaka.test.Dash = class { const playerInterface = { networkingEngine: networkingEngine, + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, filter: () => {}, makeTextStreamsForClosedCaptions: (manifest) => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. @@ -78,6 +80,8 @@ shaka.test.Dash = class { const playerInterface = { networkingEngine: networkingEngine, + modifyManifestRequest: (request, manifestInfo) => {}, + modifySegmentRequest: (request, segmentInfo) => {}, filter: () => {}, makeTextStreamsForClosedCaptions: (manifest) => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. diff --git a/test/util/cmcd_manager_unit.js b/test/util/cmcd_manager_unit.js new file mode 100644 index 0000000000..a1522656d5 --- /dev/null +++ b/test/util/cmcd_manager_unit.js @@ -0,0 +1,277 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.require('shaka.util.CmcdManager'); +goog.require('shaka.util.ObjectUtils'); + +describe('CmcdManager', () => { + const CmcdManager = shaka.util.CmcdManager; + const uuidRegex = + '[A-F\\d]{8}-[A-F\\d]{4}-4[A-F\\d]{3}-[89AB][A-F\\d]{3}-[A-F\\d]{12}'; + const data = { + 'sid': 'c936730c-031e-4a73-976f-92bc34039c60', + 'cid': 'xyz', + 'su': false, + 'nor': '../testing/3.m4v', + 'nrr': '0-99', + 'd': 6066.66, + 'mtp': 10049, + 'bs': true, + 'br': 52317, + 'v': 1, + 'pr': 1, + 'com.test-hello': 'world', + 'com.test-testing': 1234, + 'com.test-exists': true, + 'com.test-notExists': false, + 'com.test-token': Symbol('s'), + }; + + describe('Query serialization', () => { + it('produces correctly serialized data', () => { + const query = CmcdManager.toQuery(data); + const result = 'CMCD=br%3D52317%2Cbs%2Ccid%3D%22xyz%22%2C' + + 'com.test-exists%2Ccom.test-hello%3D%22world%22%2C' + + 'com.test-testing%3D1234%2Ccom.test-token%3Ds%2C' + + 'd%3D6067%2Cmtp%3D10000%2C' + + 'nor%3D%22..%252Ftesting%252F3.m4v%22%2C' + + 'nrr%3D%220-99%22%2C' + + 'sid%3D%22c936730c-031e-4a73-976f-92bc34039c60%22'; + expect(query).toBe(result); + }); + }); + + describe('Header serialization', () => { + it('produces all header shards', () => { + const header = CmcdManager.toHeaders(data); + expect(header).toEqual({ + 'CMCD-Object': 'br=52317,d=6067', + 'CMCD-Request': 'com.test-exists,com.test-hello="world",' + + 'com.test-testing=1234,com.test-token=s,mtp=10000,' + + 'nor="..%2Ftesting%2F3.m4v",nrr="0-99"', + 'CMCD-Session': 'cid="xyz",sid="c936730c-031e-4a73-976f-92bc34039c60"', + 'CMCD-Status': 'bs', + }); + }); + + it('ignores empty shards', () => { + expect(CmcdManager.toHeaders({br: 200})).toEqual({ + 'CMCD-Object': 'br=200', + }); + }); + }); + + describe('CmcdManager instance', () => { + const ObjectUtils = shaka.util.ObjectUtils; + + const playerInterface = { + isLive: () => false, + getBandwidthEstimate: () => 10000000, + getBufferedInfo: () => ({ + video: [ + {start: 0, end: 5}, + {start: 6, end: 31.234}, + {start: 35, end: 40}, + ], + }), + getManifest: () => /** @type {shaka.extern.Manifest} */({ + variants: [ + {video: {bandwidth: 50000}}, + {video: {bandwidth: 5000000}}, + ], + }), + getPlaybackRate: () => 1, + getCurrentTime: () => 10, + }; + + const sid = '2ed2d1cd-970b-48f2-bfb3-50a79e87cfa3'; + const config = { + enabled: false, + sessionId: '', + contentId: 'testing', + useHeaders: false, + }; + + /** @type shaka.util.CmcdManager */ + let cmcdManager = new CmcdManager(playerInterface, config); + + const request = { + uris: ['https://test.com/test.mpd'], + method: 'GET', + body: null, + headers: { + testing: '1234', + }, + allowCrossSiteCredentials: false, + retryParameters: /** @type {shaka.extern.RetryParameters} */({}), + licenseRequestType: null, + sessionId: null, + streamDataCallback: null, + }; + + const manifestInfo = { + format: shaka.util.CmcdManager.StreamingFormat.DASH, + }; + + const segmentInfo = { + type: 'video', + init: false, + duration: 3.33, + mimeType: 'application/mp4', + codecs: 'avc1.42001e', + bandwidth: 5234167, + }; + + describe('configuration', () => { + it('does not modify requests when disabled', () => { + const r = ObjectUtils.cloneObject(request); + + cmcdManager.applyManifestData(r, manifestInfo); + expect(r.uris[0]).toBe(request.uris[0]); + + cmcdManager.applySegmentData(r, segmentInfo); + expect(r.uris[0]).toBe(request.uris[0]); + }); + + it('generates a session id if not provided', () => { + config.enabled = true; + cmcdManager = new CmcdManager(playerInterface, config); + + const r = ObjectUtils.cloneObject(request); + + cmcdManager.applyManifestData(r, manifestInfo); + const regex = new RegExp(`sid%3D%22${uuidRegex}%22`, 'i'); + expect(regex.test(r.uris[0])).toBe(true); + }); + }); + + describe('query mode', () => { + beforeAll(() => { + config.sessionId = sid; + cmcdManager = new CmcdManager(playerInterface, config); + }); + + it('modifies manifest request uris', () => { + const r = ObjectUtils.cloneObject(request); + cmcdManager.applyManifestData(r, manifestInfo); + const uri = 'https://test.com/test.mpd?CMCD=cid%3D%22testing%22%2C' + + 'mtp%3D10000%2Cot%3Dm%2Csf%3Dd%2C' + + 'sid%3D%222ed2d1cd-970b-48f2-bfb3-50a79e87cfa3%22%2Csu'; + expect(r.uris[0]).toBe(uri); + }); + + it('modifies segment request uris', () => { + const r = ObjectUtils.cloneObject(request); + cmcdManager.applySegmentData(r, segmentInfo); + const uri = 'https://test.com/test.mpd?CMCD=bl%3D21200%2Cbr%3D5234%2Ccid%3D%22' + + 'testing%22%2Cd%3D3330%2Cmtp%3D10000%2Cot%3Dv%2Csf%3Dd%2C' + + 'sid%3D%222ed2d1cd-970b-48f2-bfb3-50a79e87cfa3%22%2Cst%3Dv%2Csu%2C' + + 'tb%3D5000'; + expect(r.uris[0]).toBe(uri); + }); + + it('modifies text request uris', () => { + const r = ObjectUtils.cloneObject(request); + cmcdManager.applyTextData(r); + const uri = 'https://test.com/test.mpd?CMCD=cid%3D%22' + + 'testing%22%2Cmtp%3D10000%2Cot%3Dc%2Csf%3Dd%2C' + + 'sid%3D%222ed2d1cd-970b-48f2-bfb3-50a79e87cfa3%22%2Csu'; + expect(r.uris[0]).toBe(uri); + }); + }); + + describe('header mode', () => { + beforeAll(() => { + config.useHeaders = true; + cmcdManager = new CmcdManager(playerInterface, config); + }); + + it('modifies manifest request headers', () => { + const r = ObjectUtils.cloneObject(request); + cmcdManager.applyManifestData(r, manifestInfo); + expect(r.headers).toEqual({ + 'testing': '1234', + 'CMCD-Object': 'ot=m', + 'CMCD-Request': 'mtp=10000,su', + 'CMCD-Session': 'cid="testing",sf=d,' + + 'sid="2ed2d1cd-970b-48f2-bfb3-50a79e87cfa3"', + }); + }); + + it('modifies segment request headers', () => { + const r = ObjectUtils.cloneObject(request); + cmcdManager.applySegmentData(r, segmentInfo); + expect(r.headers).toEqual({ + 'testing': '1234', + 'CMCD-Object': 'br=5234,d=3330,ot=v,tb=5000', + 'CMCD-Request': 'bl=21200,mtp=10000,su', + 'CMCD-Session': 'cid="testing",sf=d,' + + 'sid="2ed2d1cd-970b-48f2-bfb3-50a79e87cfa3",st=v', + }); + }); + }); + + describe('src= mode', () => { + it('modifies media stream uris', () => { + const r = cmcdManager + .appendSrcData('https://test.com/test.mp4', 'video/mp4'); + const uri = 'https://test.com/test.mp4?CMCD=cid%3D%22testing%22%2C' + + 'mtp%3D10000%2Cot%3Dav%2Csf%3Dd%2C' + + 'sid%3D%222ed2d1cd-970b-48f2-bfb3-50a79e87cfa3%22%2Csu'; + expect(r).toBe(uri); + }); + + it('modifies manifest stream uris', () => { + const r = cmcdManager + .appendSrcData('https://test.com/test.m3u8', 'application/x-mpegurl'); + const uri = 'https://test.com/test.m3u8?CMCD=cid%3D%22testing%22%2C' + + 'mtp%3D10000%2Cot%3Dm%2Csf%3Dd%2C' + + 'sid%3D%222ed2d1cd-970b-48f2-bfb3-50a79e87cfa3%22%2Csu'; + expect(r).toBe(uri); + }); + + it('modifies text track uris', () => { + const r = cmcdManager.appendTextTrackData('https://test.com/test.vtt'); + const uri = 'https://test.com/test.vtt?CMCD=cid%3D%22testing%22%2C' + + 'mtp%3D10000%2Cot%3Dc%2Csf%3Dd%2C' + + 'sid%3D%222ed2d1cd-970b-48f2-bfb3-50a79e87cfa3%22%2Csu'; + expect(r).toBe(uri); + }); + }); + + describe('adheres to the spec', () => { + beforeAll(() => { + cmcdManager.setBuffering(false); + cmcdManager.setBuffering(true); + }); + + it('sends bs only once', () => { + let r = ObjectUtils.cloneObject(request); + cmcdManager.applySegmentData(r, segmentInfo); + expect(r.headers['CMCD-Status']).toContain('bs'); + + r = ObjectUtils.cloneObject(request); + cmcdManager.applySegmentData(r, segmentInfo); + expect(r.headers['CMCD-Status']).not.toContain('bs'); + }); + + it('sends su until buffering is complete', () => { + let r = ObjectUtils.cloneObject(request); + cmcdManager.applySegmentData(r, segmentInfo); + expect(r.headers['CMCD-Request']).toContain(',su'); + + r = ObjectUtils.cloneObject(request); + cmcdManager.applySegmentData(r, segmentInfo); + expect(r.headers['CMCD-Request']).toContain(',su'); + + cmcdManager.setBuffering(false); + r = ObjectUtils.cloneObject(request); + cmcdManager.applySegmentData(r, segmentInfo); + expect(r.headers['CMCD-Request']).not.toContain(',su'); + }); + }); + }); +});