/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.util.CmcdManager');
goog.require('goog.Uri');
goog.require('shaka.log');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.ArrayUtils');
goog.requireType('shaka.media.SegmentReference');
/**
* @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;
/**
* 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;
/**
* @private {boolean}
*/
this.lowLatency_ = false;
}
/**
* Called by the Player to provide an updated configuration any time it
* changes.
*
* @param {shaka.extern.CmcdConfiguration} config
*/
configure(config) {
this.config_ = config;
}
/**
* Resets the CmcdManager.
*/
reset() {
this.playbackStarted_ = false;
this.buffering_ = true;
this.starved_ = false;
this.lowLatency_ = 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;
}
/**
* Set the low latency
*
* @param {boolean} lowLatency
*/
setLowLatency(lowLatency) {
this.lowLatency_ = lowLatency;
const StreamingFormat = shaka.util.CmcdManager.StreamingFormat;
if (this.lowLatency_) {
if (this.sf_ == StreamingFormat.DASH) {
this.sf_ = StreamingFormat.LOW_LATENCY_DASH;
} else if (this.sf_ == StreamingFormat.HLS) {
this.sf_ = StreamingFormat.LOW_LATENCY_HLS;
}
} else {
if (this.sf_ == StreamingFormat.LOW_LATENCY_DASH) {
this.sf_ = StreamingFormat.DASH;
} else if (this.sf_ == StreamingFormat.LOW_LATENCY_HLS) {
this.sf_ = StreamingFormat.HLS;
}
}
}
/**
* Apply CMCD data to a request.
*
* @param {!shaka.net.NetworkingEngine.RequestType} type
* The request type
* @param {!shaka.extern.Request} request
* The request to apply CMCD data to
* @param {shaka.extern.RequestContext=} context
* The request context
*/
applyData(type, request, context = {}) {
if (!this.config_.enabled) {
return;
}
if (request.method === 'HEAD') {
this.apply_(request);
return;
}
const RequestType = shaka.net.NetworkingEngine.RequestType;
const ObjectType = shaka.util.CmcdManager.ObjectType;
switch (type) {
case RequestType.MANIFEST:
this.applyManifestData(request, context);
break;
case RequestType.SEGMENT:
this.applySegmentData(request, context);
break;
case RequestType.LICENSE:
case RequestType.SERVER_CERTIFICATE:
case RequestType.KEY:
this.apply_(request, {ot: ObjectType.KEY});
break;
case RequestType.TIMING:
this.apply_(request, {ot: ObjectType.OTHER});
break;
}
}
/**
* Apply CMCD data to a manifest request.
*
* @param {!shaka.extern.Request} request
* The request to apply CMCD data to
* @param {shaka.extern.RequestContext} context
* The request context
*/
applyManifestData(request, context) {
try {
if (!this.config_.enabled) {
return;
}
if (context.type) {
this.sf_ = this.getStreamFormat_(context.type);
}
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.extern.RequestContext} context
* The request context
*/
applySegmentData(request, context) {
try {
if (!this.config_.enabled) {
return;
}
const segment = context.segment;
let duration = 0;
if (segment) {
duration = segment.endTime - segment.startTime;
}
const data = {
d: duration * 1000,
st: this.getStreamType_(),
};
data.ot = this.getObjectType_(context);
const ObjectType = shaka.util.CmcdManager.ObjectType;
const isMedia = data.ot === ObjectType.VIDEO ||
data.ot === ObjectType.AUDIO ||
data.ot === ObjectType.MUXED ||
data.ot === ObjectType.TIMED_TEXT;
const stream = context.stream;
if (stream) {
const playbackRate = this.playerInterface_.getPlaybackRate();
if (isMedia) {
data.bl = this.getBufferLength_(stream.type);
if (data.ot !== ObjectType.TIMED_TEXT) {
const remainingBufferLength =
this.getRemainingBufferLength_(stream.type);
if (playbackRate) {
data.dl = remainingBufferLength / Math.abs(playbackRate);
} else {
data.dl = remainingBufferLength;
}
}
}
if (stream.bandwidth) {
data.br = stream.bandwidth / 1000;
}
if (stream.segmentIndex && segment) {
const reverse = playbackRate < 0;
const iterator = stream.segmentIndex.getIteratorForTime(
segment.endTime, /* allowNonIndepedent= */ true, reverse);
if (iterator) {
const nextSegment = iterator.next().value;
if (nextSegment && nextSegment != segment) {
if (!shaka.util.ArrayUtils.equal(
segment.getUris(), nextSegment.getUris())) {
data.nor = this.urlToRelativePath_(
nextSegment.getUris()[0], request.uris[0]);
}
if ((nextSegment.startByte || nextSegment.endByte) &&
(segment.startByte != nextSegment.startByte ||
segment.endByte != nextSegment.endByte)) {
let range = nextSegment.startByte + '-';
if (nextSegment.endByte) {
range += nextSegment.endByte;
}
data.nrr = range;
}
}
}
const rtp = this.calculateRtp_(stream, segment);
if (!isNaN(rtp)) {
data.rtp = rtp;
}
}
}
if (isMedia && data.ot !== ObjectType.TIMED_TEXT) {
data.tb = this.getTopBandwidth_(data.ot) / 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 {
if (!this.config_.enabled) {
return;
}
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 {CmcdData}
* @private
*/
createData_() {
if (!this.config_.sessionId) {
this.config_.sessionId = window.crypto.randomUUID();
}
return {
v: shaka.util.CmcdManager.Version,
sf: this.sf_,
sid: this.config_.sessionId,
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 {!CmcdData} 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_;
}
const output = this.filterKeys_(data);
if (useHeaders) {
const headers = shaka.util.CmcdManager.toHeaders(output);
if (!Object.keys(headers).length) {
return;
}
Object.assign(request.headers, headers);
} else {
const query = shaka.util.CmcdManager.toQuery(output);
if (!query) {
return;
}
request.uris = request.uris.map((uri) => {
return shaka.util.CmcdManager.appendQueryToUri(uri, query);
});
}
}
/**
* Filter the CMCD data object to include only the keys specified in the
* configuration.
*
* @param {CmcdData} data
* @return {CmcdData}
* @private
*/
filterKeys_(data) {
const includeKeys = this.config_.includeKeys;
if (!includeKeys.length) {
return data;
}
return Object.keys(data).reduce((acc, key) => {
if (includeKeys.includes(key)) {
acc[key] = data[key];
}
return acc;
}, {});
}
/**
* The CMCD object type.
*
* @param {shaka.extern.RequestContext} context
* The request context
* @private
*/
getObjectType_(context) {
if (context.type ===
shaka.net.NetworkingEngine.AdvancedRequestType.INIT_SEGMENT) {
return shaka.util.CmcdManager.ObjectType.INIT;
}
const stream = context.stream;
if (!stream) {
return undefined;
}
const type = stream.type;
if (type == 'video') {
if (stream.codecs && stream.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 (stream.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.toLowerCase()) {
case 'audio/mp4':
case 'audio/webm':
case 'audio/ogg':
case 'audio/mpeg':
case 'audio/aac':
case 'audio/flac':
case 'audio/wav':
return shaka.util.CmcdManager.ObjectType.AUDIO;
case 'video/webm':
case 'video/mp4':
case 'video/mpeg':
case 'video/mp2t':
return shaka.util.CmcdManager.ObjectType.MUXED;
case 'application/x-mpegurl':
case 'application/vnd.apple.mpegurl':
case 'application/dash+xml':
case 'video/vnd.mpeg.dash.mpd':
case 'application/vnd.ms-sstr+xml':
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 remaining buffer length for a media type in milliseconds
*
* @param {string} type
* @return {number}
* @private
*/
getRemainingBufferLength_(type) {
const ranges = this.playerInterface_.getBufferedInfo()[type];
if (!ranges.length) {
return 0;
}
const start = this.playerInterface_.getCurrentTime();
const range = ranges.find((r) => r.start <= start && r.end >= start);
if (!range) {
return 0;
}
return (range.end - start) * 1000;
}
/**
* Constructs a relative path from a URL
*
* @param {string} url
* @param {string} base
* @return {string}
* @private
*/
urlToRelativePath_(url, base) {
const to = new URL(url);
const from = new URL(base);
if (to.origin !== from.origin) {
return url;
}
const toPath = to.pathname.split('/').slice(1);
const fromPath = from.pathname.split('/').slice(1, -1);
// remove common parents
while (toPath[0] === fromPath[0]) {
toPath.shift();
fromPath.shift();
}
// add back paths
while (fromPath.length) {
fromPath.shift();
toPath.unshift('..');
}
return toPath.join('/');
}
/**
* Calculate requested maximun throughput
*
* @param {shaka.extern.Stream} stream
* @param {shaka.media.SegmentReference} segment
* @return {number}
* @private
*/
calculateRtp_(stream, segment) {
const playbackRate = this.playerInterface_.getPlaybackRate() || 1;
const currentBufferLevel =
this.getRemainingBufferLength_(stream.type) || 500;
const bandwidth = stream.bandwidth;
if (!bandwidth) {
return NaN;
}
const segmentDuration = segment.endTime - segment.startTime;
// Calculate file size in kilobits
const segmentSize = bandwidth * segmentDuration / 1000;
// Calculate time available to load file in seconds
const timeToLoad = (currentBufferLevel / playbackRate) / 1000;
// Calculate the exact bandwidth required
const minBandwidth = segmentSize / timeToLoad;
// Include a safety buffer
return minBandwidth * this.config_.rtpSafetyFactor;
}
/**
* Get the stream format
*
* @param {shaka.net.NetworkingEngine.AdvancedRequestType} type
* The request's advanced type
* @return {(shaka.util.CmcdManager.StreamingFormat|undefined)}
* @private
*/
getStreamFormat_(type) {
const AdvancedRequestType = shaka.net.NetworkingEngine.AdvancedRequestType;
switch (type) {
case AdvancedRequestType.MPD:
if (this.lowLatency_) {
return shaka.util.CmcdManager.StreamingFormat.LOW_LATENCY_DASH;
}
return shaka.util.CmcdManager.StreamingFormat.DASH;
case AdvancedRequestType.MASTER_PLAYLIST:
case AdvancedRequestType.MEDIA_PLAYLIST:
if (this.lowLatency_) {
return shaka.util.CmcdManager.StreamingFormat.LOW_LATENCY_HLS;
}
return shaka.util.CmcdManager.StreamingFormat.HLS;
case AdvancedRequestType.MSS:
return shaka.util.CmcdManager.StreamingFormat.SMOOTH;
}
return undefined;
}
/**
* 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 variants = this.playerInterface_.getVariantTracks();
if (!variants.length) {
return NaN;
}
let top = variants[0];
for (const variant of variants) {
if (variant.type === 'variant' && variant.bandwidth > top.bandwidth) {
top = variant;
}
}
const ObjectType = shaka.util.CmcdManager.ObjectType;
switch (type) {
case ObjectType.VIDEO:
return top.videoBandwidth || NaN;
case ObjectType.AUDIO:
return top.audioBandwidth || NaN;
default:
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 {CmcdData} 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}=${JSON.stringify(value)}`;
} 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 {CmcdData} 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 {CmcdData} data The CMCD data object
* @return {string}
*/
static toQuery(data) {
return 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 url = new goog.Uri(uri);
url.getQueryData().set('CMCD', query);
return url.toString();
}
};
/**
* @typedef {{
* getBandwidthEstimate: function():number,
* getBufferedInfo: function():shaka.extern.BufferedInfo,
* getCurrentTime: function():number,
* getPlaybackRate: function():number,
* getVariantTracks: function():Array.<shaka.extern.Track>,
* 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():number} getPlaybackRate
* Get the playback rate
* @property {function():Array.<shaka.extern.Track>} getVariantTracks
* Get the variant tracks
* @property {function():boolean} isLive
* Get if the player is playing live content.
*/
shaka.util.CmcdManager.PlayerInterface;
/**
* @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',
LOW_LATENCY_DASH: 'ld',
HLS: 'h',
LOW_LATENCY_HLS: 'lh',
SMOOTH: 's',
OTHER: 'o',
};
/**
* The CMCD spec version
* @const {number}
*/
shaka.util.CmcdManager.Version = 1;