diff --git a/.eslintrc.js b/.eslintrc.js index b287f88a0c8..f66a8a0a535 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,6 +13,7 @@ module.exports = { __USE_SUBTITLES__: true, __USE_ALT_AUDIO__: true, __USE_EME_DRM__: true, + __USE_CMCD__: true, }, // see https://github.com/standard/eslint-config-standard // 'prettier' (https://github.com/prettier/eslint-config-prettier) must be last diff --git a/.gitignore b/.gitignore index d3dfb37ca70..6c490cc1ce0 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ Generated\ Files/ _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user + +# VSCode custom workspace settings +.vscode/settings.json diff --git a/README.md b/README.md index eecb760a81c..b318c708e27 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ npm run build -- --env dist # replace "dist" by other configuration name, see ab Note: The "demo" config is always built. -**NOTE:** `hls.light.*.js` dist files do not include EME, subtitles, or alternate-audio support. In addition, +**NOTE:** `hls.light.*.js` dist files do not include EME, subtitles, CMCD, or alternate-audio support. In addition, the following types are not available in the light build: - `AudioStreamController` @@ -186,6 +186,7 @@ the following types are not available in the light build: - `SubtitleStreamController` - `SubtitleTrackController` - `TimelineController` +- `CmcdController` ### Linter (ESlint) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index c339c82c677..1652f30bcd6 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -123,7 +123,7 @@ export class BaseSegment { // (undocumented) get url(): string; set url(value: string); - } +} // Warning: (ae-missing-release-tag) "BufferAppendedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -251,6 +251,15 @@ export class ChunkMetadata { readonly transmuxing: HlsChunkPerformanceTiming; } +// Warning: (ae-missing-release-tag) "CMCDControllerConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type CMCDControllerConfig = { + sessionId?: string; + contentId?: string; + useHeaders?: boolean; +}; + // Warning: (ae-missing-release-tag) "CuesInterface" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -759,15 +768,21 @@ export class Fragment extends BaseSegment { // // @public (undocumented) export type FragmentLoaderConfig = { - fLoader?: { - new (confg: HlsConfig): Loader; - }; + fLoader?: FragmentLoaderConstructor; fragLoadingTimeOut: number; fragLoadingMaxRetry: number; fragLoadingRetryDelay: number; fragLoadingMaxRetryTimeout: number; }; +// Warning: (ae-missing-release-tag) "FragmentLoaderConstructor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface FragmentLoaderConstructor { + // (undocumented) + new (confg: HlsConfig): Loader; +} + // Warning: (ae-missing-release-tag) "FragmentLoaderContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -925,7 +940,6 @@ class Hls implements HlsEventEmitter { // (undocumented) static get version(): string; } - export default Hls; // Warning: (ae-missing-release-tag) "HlsChunkPerformanceTiming" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -957,6 +971,8 @@ export type HlsConfig = { subtitleTrackController?: typeof SubtitleTrackController; timelineController?: typeof TimelineController; emeController?: typeof EMEController; + cmcd?: CMCDControllerConfig; + cmcdController?: typeof CMCDController; abrController: typeof AbrController; bufferController: typeof BufferController; capLevelController: typeof CapLevelController; @@ -1426,7 +1442,7 @@ export class LevelKey { method: string | null; // (undocumented) get uri(): string | null; - } +} // Warning: (ae-missing-release-tag) "LevelLoadedData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1605,6 +1621,8 @@ export interface LoaderConfiguration { // // @public (undocumented) export interface LoaderContext { + // (undocumented) + headers?: Record; // (undocumented) progressData?: boolean; // (undocumented) @@ -1917,9 +1935,7 @@ export enum PlaylistLevelType { // // @public (undocumented) export type PlaylistLoaderConfig = { - pLoader?: { - new (confg: HlsConfig): Loader; - }; + pLoader?: PlaylistLoaderConstructor; manifestLoadingTimeOut: number; manifestLoadingMaxRetry: number; manifestLoadingRetryDelay: number; @@ -1930,6 +1946,14 @@ export type PlaylistLoaderConfig = { levelLoadingMaxRetryTimeout: number; }; +// Warning: (ae-missing-release-tag) "PlaylistLoaderConstructor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface PlaylistLoaderConstructor { + // (undocumented) + new (confg: HlsConfig): Loader; +} + // Warning: (ae-missing-release-tag) "PlaylistLoaderContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2124,20 +2148,20 @@ export interface UserdataSample { pts: number; } - // Warnings were encountered during analysis: // -// src/config.ts:148:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts -// src/config.ts:157:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:158:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:160:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:161:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:162:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts -// src/config.ts:164:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts -// src/config.ts:166:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts -// src/config.ts:167:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts -// src/config.ts:168:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts -// src/config.ts:169:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts +// src/config.ts:163:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts +// src/config.ts:172:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:173:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:175:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:176:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:177:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts +// src/config.ts:179:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts +// src/config.ts:182:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts +// src/config.ts:184:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts +// src/config.ts:185:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts +// src/config.ts:186:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts +// src/config.ts:187:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts // (No @packageDocumentation comment for this package) diff --git a/demo/basic-usage.html b/demo/basic-usage.html index 6587bd054a9..c255cf2ba3c 100644 --- a/demo/basic-usage.html +++ b/demo/basic-usage.html @@ -25,7 +25,7 @@

Hls.js demo - basic usage

}); } // hls.js is not supported on platforms that do not have Media Source Extensions (MSE) enabled. - // When the browser has built-in HLS support (check using `canPlayType`), we can provide an HLS manifest (i.e. .m3u8 URL) directly to the video element throught the `src` property. + // When the browser has built-in HLS support (check using `canPlayType`), we can provide an HLS manifest (i.e. .m3u8 URL) directly to the video element through the `src` property. // This is using the built-in support of the plain video element, without using hls.js. else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; diff --git a/docs/API.md b/docs/API.md index 53e6e06c1cb..f7368c24de7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -94,6 +94,7 @@ - [`licenseResponseCallback`](#licenseResponseCallback) - [`drmSystemOptions`](#drmSystemOptions) - [`requestMediaKeySystemAccessFunc`](#requestMediaKeySystemAccessFunc) + - [`cmcd`](#cmcd) - [Video Binding/Unbinding API](#video-bindingunbinding-api) - [`hls.attachMedia(videoElement)`](#hlsattachmediavideoelement) - [`hls.detachMedia()`](#hlsdetachmedia) @@ -391,6 +392,7 @@ var config = { licenseXhrSetup: undefined, drmSystemOptions: {}, requestMediaKeySystemAccessFunc: requestMediaKeySystemAccess, + cmcd: undefined, }; var hls = new Hls(config); @@ -1192,6 +1194,15 @@ With the default argument, `''` will be specified for each option (_i.e. no spec Allows for the customization of `window.navigator.requestMediaKeySystemAccess`. +### `cmcd` + +When the `cmcd` object is defined, [Common Media Client Data (CMCD)](https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf) +data will be passed on all media requests (manifests, playlists, a/v segments, timed text). It's configuration values are: + +- `sessionId`: The CMCD session id. One will be automatically generated if none is provided. +- `contentId`: The CMCD content id. +- `useHeaders`: Send CMCD data in request headers instead of as query args. Defaults to `false`. + ## Video Binding/Unbinding API ### `hls.attachMedia(videoElement)` diff --git a/src/config.ts b/src/config.ts index f83123cc8e4..2ea20f28fe3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ import { TimelineController } from './controller/timeline-controller'; import CapLevelController from './controller/cap-level-controller'; import FPSController from './controller/fps-controller'; import EMEController from './controller/eme-controller'; +import CMCDController from './controller/cmcd-controller'; import XhrLoader from './utils/xhr-loader'; import FetchLoader, { fetchSupported } from './utils/fetch-loader'; import Cues from './utils/cues'; @@ -47,6 +48,12 @@ export type CapLevelControllerConfig = { capLevelToPlayerSize: boolean; }; +export type CMCDControllerConfig = { + sessionId?: string; + contentId?: string; + useHeaders?: boolean; +}; + export type DRMSystemOptions = { audioRobustness?: string; videoRobustness?: string; @@ -61,8 +68,12 @@ export type EMEControllerConfig = { requestMediaKeySystemAccessFunc: MediaKeyFunc | null; }; +export interface FragmentLoaderConstructor { + new (confg: HlsConfig): Loader; +} + export type FragmentLoaderConfig = { - fLoader?: { new (confg: HlsConfig): Loader }; + fLoader?: FragmentLoaderConstructor; fragLoadingTimeOut: number; fragLoadingMaxRetry: number; @@ -85,8 +96,12 @@ export type MP4RemuxerConfig = { maxAudioFramesDrift: number; }; +export interface PlaylistLoaderConstructor { + new (confg: HlsConfig): Loader; +} + export type PlaylistLoaderConfig = { - pLoader?: { new (confg: HlsConfig): Loader }; + pLoader?: PlaylistLoaderConstructor; manifestLoadingTimeOut: number; manifestLoadingMaxRetry: number; @@ -162,6 +177,9 @@ export type HlsConfig = { timelineController?: typeof TimelineController; // EME emeController?: typeof EMEController; + // CMCD + cmcd?: CMCDControllerConfig; + cmcdController?: typeof CMCDController; abrController: typeof AbrController; bufferController: typeof BufferController; @@ -261,6 +279,7 @@ export const hlsDefaultConfig: HlsConfig = { testBandwidth: true, progressive: false, lowLatencyMode: true, + cmcd: undefined, // Dynamic Modules ...timelineConfig(), @@ -274,6 +293,7 @@ export const hlsDefaultConfig: HlsConfig = { audioStreamController: __USE_ALT_AUDIO__ ? AudioStreamController : undefined, audioTrackController: __USE_ALT_AUDIO__ ? AudioTrackController : undefined, emeController: __USE_EME_DRM__ ? EMEController : undefined, + cmcdController: __USE_CMCD__ ? CMCDController : undefined, }; function timelineConfig(): TimelineControllerConfig { diff --git a/src/controller/cmcd-controller.ts b/src/controller/cmcd-controller.ts new file mode 100644 index 00000000000..7504011126c --- /dev/null +++ b/src/controller/cmcd-controller.ts @@ -0,0 +1,531 @@ +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); + 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; + + const levels = + type === CMCDObjectType.AUDIO ? this.hls.audioTracks : this.hls.levels; + + 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; + + 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 + ) { + 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; + + 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 + ) { + 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.substr(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 { + 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}`; + } +} diff --git a/src/define-plugin.d.ts b/src/define-plugin.d.ts index 745cca5a931..8aa26559a35 100644 --- a/src/define-plugin.d.ts +++ b/src/define-plugin.d.ts @@ -4,3 +4,4 @@ declare const __VERSION__: string; declare const __USE_ALT_AUDIO__: boolean; declare const __USE_EME_DRM__: boolean; declare const __USE_SUBTITLES__: boolean; +declare const __USE_CMCD__: boolean; diff --git a/src/hls.ts b/src/hls.ts index de907de9d6b..3dadb8754f1 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -17,6 +17,7 @@ import type AudioTrackController from './controller/audio-track-controller'; import type AbrController from './controller/abr-controller'; import type BufferController from './controller/buffer-controller'; import type CapLevelController from './controller/cap-level-controller'; +import type CMCDController from './controller/cmcd-controller'; import type EMEController from './controller/eme-controller'; import type SubtitleTrackController from './controller/subtitle-track-controller'; import type { ComponentAPI, NetworkComponentAPI } from './types/component-api'; @@ -50,6 +51,7 @@ export default class Hls implements HlsEventEmitter { private audioTrackController: AudioTrackController; private subtitleTrackController: SubtitleTrackController; private emeController: EMEController; + private cmcdController: CMCDController; private _media: HTMLMediaElement | null = null; private url: string | null = null; @@ -118,6 +120,7 @@ export default class Hls implements HlsEventEmitter { new ConfigBufferController(this)); const capLevelController = (this.capLevelController = new ConfigCapLevelController(this)); + const fpsController = new ConfigFpsController(this); const playListLoader = new PlaylistLoader(this); const keyLoader = new KeyLoader(this); @@ -178,6 +181,11 @@ export default class Hls implements HlsEventEmitter { null, coreComponents ); + this.cmcdController = this.createController( + config.cmcdController, + null, + coreComponents + ); this.latencyController = this.createController( LatencyController, null, @@ -831,13 +839,16 @@ export type { ABRControllerConfig, BufferControllerConfig, CapLevelControllerConfig, + CMCDControllerConfig, EMEControllerConfig, DRMSystemOptions, FPSControllerConfig, FragmentLoaderConfig, + FragmentLoaderConstructor, LevelControllerConfig, MP4RemuxerConfig, PlaylistLoaderConfig, + PlaylistLoaderConstructor, StreamControllerConfig, LatencyControllerConfig, TimelineControllerConfig, diff --git a/src/loader/fragment-loader.ts b/src/loader/fragment-loader.ts index 70d33d88a90..7dd4228029f 100644 --- a/src/loader/fragment-loader.ts +++ b/src/loader/fragment-loader.ts @@ -279,6 +279,7 @@ function createLoaderContext( part, responseType: 'arraybuffer', url: segment.url, + headers: {}, rangeStart: 0, rangeEnd: 0, }; diff --git a/src/types/cmcd.ts b/src/types/cmcd.ts new file mode 100644 index 00000000000..89ddf2c59c2 --- /dev/null +++ b/src/types/cmcd.ts @@ -0,0 +1,276 @@ +/** + * CMCD spec version + */ +export const CMCDVersion = 1; + +/** + * CMCD Object Type + */ +export enum CMCDObjectType { + MANIFEST = 'm', + AUDIO = 'a', + VIDEO = 'v', + MUXED = 'av', + INIT = 'i', + CAPTION = 'c', + TIMED_TEXT = 'tt', + KEY = 'k', + OTHER = 'o', +} + +/** + * CMCD Streaming Format + */ +export enum CMCDStreamingFormat { + DASH = 'd', + HLS = 'h', + SMOOTH = 's', + OTHER = 'o', +} + +/** + * CMCD Streaming Type + */ +export enum CMCDStreamType { + VOD = 'v', + LIVE = 'l', +} + +/** + * CMCD Headers + */ +export interface CMCDHeaders { + 'CMCD-Object': string; + 'CMCD-Request': string; + 'CMCD-Session': string; + 'CMCD-Status': string; +} + +/** + * CMCD + */ +export interface CMCD { + ///////////////// + // CMCD Object // + ///////////////// + + /** + * Encoded bitrate + * + * 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. + * + * Integer kbps + */ + br?: number; + + /** + * Object duration + * + * 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. + * + * Integer milliseconds + */ + d?: number; + + /** + * Object type + * + * 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. + */ + ot?: CMCDObjectType; + + /** + * Top bitrate + * + * The highest bitrate rendition in the manifest or playlist that the client is allowed to play, given current codec, licensing and + * sizing constraints. + * + * Integer Kbps + */ + tb?: number; + + ////////////////// + // CMCD Request // + ////////////////// + /** + * Buffer length + * + * 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’. + * + * Integer milliseconds + */ + bl?: number; + + /** + * Deadline + * + * 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. + * + * Integer milliseconds + */ + dl?: number; + + /** + * Measured mtp CMCD throughput + * + * 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. + * + * Integer kbps + */ + mtp?: number; + + /** + * Next object request + * + * 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. + * + * String + */ + nor?: string; + + /** + * Next range request + * + * 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: + * + * - `"\-"` + * - `"\-\"` + * - `"-\"` + * + * String + */ + nrr?: string; + + /** + * Startup + * + * 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. + * + * Boolean + */ + su?: boolean; + + ////////////////// + // CMCD Session // + ////////////////// + + /** + * Content ID + * + * 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. + * + * String + */ + cid?: string; + + /** + * Playback rate + * + * `1` if real-time, `2` if double speed, `0` if not playing. SHOULD only be sent if not equal to `1`. + * + * Decimal + */ + pr?: number; + + /** + * Streaming format + * + * 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. + */ + sf?: CMCDStreamingFormat; + + /** + * Session ID + * + * 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. + * + * String + */ + sid?: string; + + /** + * Stream type + * - `v` = all segments are available – e.g., VOD + * - `l` = segments become available over time – e.g., LIVE + */ + st?: CMCDStreamType; + + /** + * CMCD version + * + * 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. + * + * Integer + */ + v?: number; + + ///////////////// + // CMCD Status // + ///////////////// + + /** + * 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. + * + * Boolean + */ + bs?: boolean; + + /** + * 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. + * + * Integer kbps + */ + rtp?: number; +} diff --git a/src/types/loader.ts b/src/types/loader.ts index 67b38f599d1..31ad2c9d016 100644 --- a/src/types/loader.ts +++ b/src/types/loader.ts @@ -8,6 +8,8 @@ export interface LoaderContext { url: string; // loader response type (arraybuffer or default response type for playlist) responseType: string; + // headers + headers?: Record; // start byte range offset rangeStart?: number; // end byte range offset diff --git a/src/utils/fetch-loader.ts b/src/utils/fetch-loader.ts index a9c0b6ad9f6..eee56123144 100644 --- a/src/utils/fetch-loader.ts +++ b/src/utils/fetch-loader.ts @@ -225,12 +225,14 @@ function getRequestParameters(context: LoaderContext, signal): any { mode: 'cors', credentials: 'same-origin', signal, + headers: new self.Headers(Object.assign({}, context.headers)), }; if (context.rangeEnd) { - initParams.headers = new self.Headers({ - Range: 'bytes=' + context.rangeStart + '-' + String(context.rangeEnd - 1), - }); + initParams.headers.set( + 'Range', + 'bytes=' + context.rangeStart + '-' + String(context.rangeEnd - 1) + ); } return initParams; diff --git a/src/utils/xhr-loader.ts b/src/utils/xhr-loader.ts index d337f44adae..7bf21619047 100644 --- a/src/utils/xhr-loader.ts +++ b/src/utils/xhr-loader.ts @@ -85,6 +85,13 @@ class XhrLoader implements Loader { const xhrSetup = this.xhrSetup; try { + const headers = this.context.headers; + if (headers) { + for (const header in headers) { + xhr.setRequestHeader(header, headers[header]); + } + } + if (xhrSetup) { try { xhrSetup(xhr, context.url); diff --git a/tests/unit/controller/cmcd-controller.ts b/tests/unit/controller/cmcd-controller.ts new file mode 100644 index 00000000000..8441c321bf8 --- /dev/null +++ b/tests/unit/controller/cmcd-controller.ts @@ -0,0 +1,113 @@ +import CMCDController from '../../../src/controller/cmcd-controller'; +import { CMCDControllerConfig } from '../../../src/hls'; +import HlsMock from '../../mocks/hls.mock'; +import * as chai from 'chai'; + +const expect = chai.expect; + +let cmcdController; + +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, +}; + +const setupEach = function (cmcd?: CMCDControllerConfig) { + cmcdController = new CMCDController(new HlsMock({ cmcd })); +}; + +describe('CMCDController', function () { + describe('Query serialization', function () { + it('produces correctly serialized data', function () { + const query = CMCDController.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%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).to.equal(result); + }); + + it('appends with ?', function () { + const result = CMCDController.appendQueryToUri( + 'http://test.com', + 'CMCD=d%3D6067' + ); + expect(result).to.equal('http://test.com?CMCD=d%3D6067'); + }); + + it('appends with &', function () { + const result = CMCDController.appendQueryToUri( + 'http://test.com?testing=123', + 'CMCD=d%3D6067' + ); + expect(result).to.equal('http://test.com?testing=123&CMCD=d%3D6067'); + }); + }); + + describe('Header serialization', function () { + it('produces all header shards', function () { + const header = CMCDController.toHeaders(data); + expect(header).to.deep.equal({ + 'CMCD-Object': 'br=52317,d=6067', + 'CMCD-Request': + 'com.test-exists,com.test-hello="world",' + + 'com.test-testing=1234,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', function () { + expect(CMCDController.toHeaders({ br: 200 })).to.deep.equal({ + 'CMCD-Object': 'br=200', + }); + }); + }); + + describe('cmcdController instance', function () { + const context = { + url: 'https://test.com/test.mpd', + }; + + describe('configuration', function () { + it('does not modify requests when disabled', function () { + setupEach(); + + const { config } = cmcdController.hls; + expect(config.pLoader).to.equal(undefined); + expect(config.fLoader).to.equal(undefined); + }); + + it('generates a session id if not provided', function () { + setupEach({}); + + const c = Object.assign({ frag: {} }, context); + + cmcdController.applyPlaylistData(c); + const regex = new RegExp(`sid%3D%22${uuidRegex}%22`, 'i'); + expect(regex.test(c.url)).to.equal(true); + }); + }); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 758ab232917..5f9882acd29 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -10,6 +10,7 @@ const env = process.env; const addSubtitleSupport = !!env.SUBTITLE || !!env.USE_SUBTITLES; const addAltAudioSupport = !!env.ALT_AUDIO || !!env.USE_ALT_AUDIO; const addEMESupport = !!env.EME_DRM || !!env.USE_EME_DRM; +const addCMCDSupport = !!env.CMCD || !!env.USE_CMCD; const createDefinePlugin = (type) => { const buildConstants = { @@ -17,6 +18,7 @@ const createDefinePlugin = (type) => { __USE_SUBTITLES__: JSON.stringify(type === 'main' || addSubtitleSupport), __USE_ALT_AUDIO__: JSON.stringify(type === 'main' || addAltAudioSupport), __USE_EME_DRM__: JSON.stringify(type === 'main' || addEMESupport), + __USE_CMCD__: JSON.stringify(type === 'main' || addCMCDSupport), }; return new webpack.DefinePlugin(buildConstants); }; @@ -127,6 +129,12 @@ function getAliasesForLightDist() { }); } + if (!addCMCDSupport) { + aliases = Object.assign({}, aliases, { + './controller/cmcd-controller': './empty.js', + }); + } + if (!addSubtitleSupport) { aliases = Object.assign(aliases, { './utils/cues': './empty.js',