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}`;
- }
- }