src/controller/cmcd-controller.ts
import {
FragmentLoaderConstructor,
HlsConfig,
PlaylistLoaderConstructor,
} from '../config';
import { Events } from '../events';
import Hls, { Fragment } from '../hls';
import {
CMCD,
CMCDHeaders,
CMCDObjectType,
CMCDStreamingFormat,
CMCDVersion,
} from '../types/cmcd';
import { ComponentAPI } from '../types/component-api';
import { BufferCreatedData, MediaAttachedData } from '../types/events';
import {
FragmentLoaderContext,
Loader,
LoaderCallbacks,
LoaderConfiguration,
LoaderContext,
PlaylistLoaderContext,
} from '../types/loader';
import { BufferHelper } from '../utils/buffer-helper';
import { logger } from '../utils/logger';
/**
* Controller to deal with Common Media Client Data (CMCD)
* @see https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf
*/
export default class CMCDController implements ComponentAPI {
private hls: Hls;
private config: HlsConfig;
private media?: HTMLMediaElement;
private sid?: string;
private cid?: string;
private useHeaders: boolean = false;
private initialized: boolean = false;
private starved: boolean = false;
private buffering: boolean = true;
private audioBuffer?: SourceBuffer; // eslint-disable-line no-restricted-globals
private videoBuffer?: SourceBuffer; // eslint-disable-line no-restricted-globals
constructor(hls: Hls) {
this.hls = hls;
const config = (this.config = hls.config);
const { cmcd } = config;
if (cmcd != null) {
config.pLoader = this.createPlaylistLoader();
config.fLoader = this.createFragmentLoader();
this.sid = cmcd.sessionId || CMCDController.uuid();
this.cid = cmcd.contentId;
this.useHeaders = cmcd.useHeaders === true;
this.registerListeners();
}
}
private registerListeners() {
const hls = this.hls;
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this);
hls.on(Events.BUFFER_CREATED, this.onBufferCreated, this);
}
private unregisterListeners() {
const hls = this.hls;
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this);
hls.off(Events.BUFFER_CREATED, this.onBufferCreated, this);
this.onMediaDetached();
}
destroy() {
this.unregisterListeners();
// @ts-ignore
this.hls = this.config = this.audioBuffer = this.videoBuffer = null;
}
private onMediaAttached(
event: Events.MEDIA_ATTACHED,
data: MediaAttachedData
) {
this.media = data.media;
this.media.addEventListener('waiting', this.onWaiting);
this.media.addEventListener('playing', this.onPlaying);
}
private onMediaDetached() {
if (!this.media) {
return;
}
this.media.removeEventListener('waiting', this.onWaiting);
this.media.removeEventListener('playing', this.onPlaying);
// @ts-ignore
this.media = null;
}
private onBufferCreated(
event: Events.BUFFER_CREATED,
data: BufferCreatedData
) {
this.audioBuffer = data.tracks.audio?.buffer;
this.videoBuffer = data.tracks.video?.buffer;
}
private onWaiting = () => {
if (this.initialized) {
this.starved = true;
}
this.buffering = true;
};
private onPlaying = () => {
if (!this.initialized) {
this.initialized = true;
}
this.buffering = false;
};
/**
* Create baseline CMCD data
*/
private createData(): CMCD {
return {
v: CMCDVersion,
sf: CMCDStreamingFormat.HLS,
sid: this.sid,
cid: this.cid,
pr: this.media?.playbackRate,
mtp: this.hls.bandwidthEstimate / 1000,
};
}
/**
* Apply CMCD data to a request.
*/
private apply(context: LoaderContext, data: CMCD = {}) {
// apply baseline data
Object.assign(data, this.createData());
const isVideo =
data.ot === CMCDObjectType.INIT ||
data.ot === CMCDObjectType.VIDEO ||
data.ot === CMCDObjectType.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 (this.useHeaders) {
const headers = CMCDController.toHeaders(data);
if (!Object.keys(headers).length) {
return;
}
if (!context.headers) {
context.headers = {};
}
Object.assign(context.headers, headers);
} else {
const query = CMCDController.toQuery(data);
if (!query) {
return;
}
context.url = CMCDController.appendQueryToUri(context.url, query);
}
}
/**
* Apply CMCD data to a manifest request.
*/
private applyPlaylistData = (context: PlaylistLoaderContext) => {
try {
this.apply(context, {
ot: CMCDObjectType.MANIFEST,
su: !this.initialized,
});
} catch (error) {
logger.warn('Could not generate manifest CMCD data.', error);
}
};
/**
* Apply CMCD data to a segment request
*/
private applyFragmentData = (context: FragmentLoaderContext) => {
try {
const fragment = context.frag;
const level = this.hls.levels[fragment.level];
const ot = this.getObjectType(fragment);
const data: CMCD = {
d: fragment.duration * 1000,
ot,
};
if (
ot === CMCDObjectType.VIDEO ||
ot === CMCDObjectType.AUDIO ||
ot == CMCDObjectType.MUXED
) {
data.br = level.bitrate / 1000;
data.tb = this.getTopBandwidth(ot) / 1000;
data.bl = this.getBufferLength(ot);
}
this.apply(context, data);
} catch (error) {
logger.warn('Could not generate segment CMCD data.', error);
}
};
/**
* The CMCD object type.
*/
private getObjectType(fragment: Fragment): CMCDObjectType | undefined {
const { type } = fragment;
if (type === 'subtitle') {
return CMCDObjectType.TIMED_TEXT;
}
if (fragment.sn === 'initSegment') {
return CMCDObjectType.INIT;
}
if (type === 'audio') {
return CMCDObjectType.AUDIO;
}
if (type === 'main') {
if (!this.hls.audioTracks.length) {
return CMCDObjectType.MUXED;
}
return CMCDObjectType.VIDEO;
}
return undefined;
}
/**
* Get the highest bitrate.
*/
private getTopBandwidth(type: CMCDObjectType) {
let bitrate: number = 0;
let levels;
const hls = this.hls;
if (type === CMCDObjectType.AUDIO) {
levels = hls.audioTracks;
} else {
const max = hls.maxAutoLevel;
const len = max > -1 ? max + 1 : hls.levels.length;
levels = hls.levels.slice(0, len);
}
for (const level of levels) {
if (level.bitrate > bitrate) {
bitrate = level.bitrate;
}
}
return bitrate > 0 ? bitrate : NaN;
}
/**
* Get the buffer length for a media type in milliseconds
*/
private getBufferLength(type: CMCDObjectType) {
const media = this.hls.media;
const buffer =
type === CMCDObjectType.AUDIO ? this.audioBuffer : this.videoBuffer;
if (!buffer || !media) {
return NaN;
}
const info = BufferHelper.bufferInfo(
buffer,
media.currentTime,
this.config.maxBufferHole
);
return info.len * 1000;
}
/**
* Create a playlist loader
*/
private createPlaylistLoader(): PlaylistLoaderConstructor | undefined {
const { pLoader } = this.config;
const apply = this.applyPlaylistData;
const Ctor = pLoader || (this.config.loader as PlaylistLoaderConstructor);
return class CmcdPlaylistLoader {
private loader: Loader<PlaylistLoaderContext>;
constructor(config: HlsConfig) {
this.loader = new Ctor(config);
}
get stats() {
return this.loader.stats;
}
get context() {
return this.loader.context;
}
destroy() {
this.loader.destroy();
}
abort() {
this.loader.abort();
}
load(
context: PlaylistLoaderContext,
config: LoaderConfiguration,
callbacks: LoaderCallbacks<PlaylistLoaderContext>
) {
apply(context);
this.loader.load(context, config, callbacks);
}
};
}
/**
* Create a playlist loader
*/
private createFragmentLoader(): FragmentLoaderConstructor | undefined {
const { fLoader } = this.config;
const apply = this.applyFragmentData;
const Ctor = fLoader || (this.config.loader as FragmentLoaderConstructor);
return class CmcdFragmentLoader {
private loader: Loader<FragmentLoaderContext>;
constructor(config: HlsConfig) {
this.loader = new Ctor(config);
}
get stats() {
return this.loader.stats;
}
get context() {
return this.loader.context;
}
destroy() {
this.loader.destroy();
}
abort() {
this.loader.abort();
}
load(
context: FragmentLoaderContext,
config: LoaderConfiguration,
callbacks: LoaderCallbacks<FragmentLoaderContext>
) {
apply(context);
this.loader.load(context, config, callbacks);
}
};
}
/**
* Generate a random v4 UUI
*
* @returns {string}
*/
static uuid(): string {
const url = URL.createObjectURL(new Blob());
const uuid = url.toString();
URL.revokeObjectURL(url);
return uuid.slice(uuid.lastIndexOf('/') + 1);
}
/**
* 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).
*/
static serialize(data: CMCD): string {
const results: string[] = [];
const isValid = (value: any) =>
!Number.isNaN(value) && value != null && value !== '' && value !== false;
const toRounded = (value: number) => Math.round(value);
const toHundred = (value: number) => toRounded(value / 100) * 100;
const toUrlSafe = (value: string) => 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: string;
if (key === 'ot' || key === 'sf' || key === 'st') {
result = `${key}=${value}`;
} else if (type === 'boolean') {
result = key;
} else if (type === 'number') {
result = `${key}=${value}`;
} else {
result = `${key}=${JSON.stringify(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).
*/
static toHeaders(data: CMCD): Partial<CMCDHeaders> {
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 = CMCDController.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).
*/
static toQuery(data: CMCD): string {
return `CMCD=${encodeURIComponent(CMCDController.serialize(data))}`;
}
/**
* Append query args to a uri.
*/
static appendQueryToUri(uri, query) {
if (!query) {
return uri;
}
const separator = uri.includes('?') ? '&' : '?';
return `${uri}${separator}${query}`;
}
}