/*! @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;
}
if (uri.includes('offline:')) {
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:
*
* - `"\<range-start\>-"`
* - `"\<range-start\>-\<range-end\>"`
* - `"-\<suffix-length\>"`
*
* @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;