diff --git a/.changeset/happy-beans-hammer.md b/.changeset/happy-beans-hammer.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/happy-beans-hammer.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.idea/web-connectors.iml b/.idea/web-connectors.iml index 4e0bc000..a92d84c5 100644 --- a/.idea/web-connectors.iml +++ b/.idea/web-connectors.iml @@ -8,6 +8,7 @@ + diff --git a/cmcd/.gitignore b/cmcd/.gitignore new file mode 100644 index 00000000..bf62141d --- /dev/null +++ b/cmcd/.gitignore @@ -0,0 +1,24 @@ +# These are some examples of commonly ignored file patterns. +# You should customize this list as applicable to your project. +# Learn more about .gitignore: +# https://www.atlassian.com/git/tutorials/saving-changes/gitignore + +# Node artifact files +node_modules/ +lib/ +dist/ + +# JetBrains IDE +.idea/ + +# Unit test reports +TEST*.xml + +# Generated by MacOS +.DS_Store + +# Generated by Windows +Thumbs.db + +# THEOplayer build and TypeScript definitions +local/ diff --git a/cmcd/CHANGELOG.md b/cmcd/CHANGELOG.md new file mode 100644 index 00000000..a12c37cc --- /dev/null +++ b/cmcd/CHANGELOG.md @@ -0,0 +1,7 @@ +# @theoplayer/cmcd-connector-web + +## 1.0.0 + +### ✨ Features + +- Initial release diff --git a/cmcd/LICENSE.md b/cmcd/LICENSE.md new file mode 100644 index 00000000..146db44c --- /dev/null +++ b/cmcd/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 THEO Technologies NV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cmcd/README.md b/cmcd/README.md new file mode 100644 index 00000000..b69968ac --- /dev/null +++ b/cmcd/README.md @@ -0,0 +1,88 @@ +# cmcd-connector-web + +A connector between a THEOplayer instance and a Common Media Client Data (CMCD) server for the THEOplayer +HTML5/Tizen/webOS SDK. This implementation supports CMCD data as defined in CTA-5004, published in September 2020. + +## Prerequisites +In order to use this connector, a [THEOplayer](https://www.npmjs.com/package/theoplayer) build with a valid license is required. You can use your existing THEOplayer HTML5 SDK license or request yours via [THEOportal](https://portal.theoplayer.com/). + +## Installation + +Install using your favorite package manager for Node (such as `npm` or `yarn`): + +### Install via npm + +```bash +npm install @theoplayer/cmcd-connector-web +``` + +### Install via yarn + +```bash +yarn add @theoplayer/cmcd-connector-web +``` + +## Usage + +First you need to add the CMCD connector to your app : + +* Add as a regular script + +```html + + +``` + +* Add as an ES2015 module + +```html + +``` + +By default, the data is sent via query arguments, but you can configure the transmission mode before creating the CMCD connector. For example, to transmit via HTTP headers: + +* regular script + +```html + + +``` + +* ES2015 module + +```html + +``` + +The connector will be automatically destroyed upon destruction of the provided player. When changing the player source and a content ID is +being passed in, this is to be reset through `reconfigure()` as it will not be cleared automatically. + +## Remarks +Note that when native playback is being used, either through THEOplayer's configuration, or due to absence of MSE/EME +APIs, the JSON Object transmission mode should be used. + +Currently, all standardized reserved keys are reported, except: + +- Object duration (`d`) +- Next object request (`nor`) +- Next range request (`nrr`) diff --git a/cmcd/jest.config.js b/cmcd/jest.config.js new file mode 100644 index 00000000..78e93fe8 --- /dev/null +++ b/cmcd/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node" +}; diff --git a/cmcd/package.json b/cmcd/package.json new file mode 100644 index 00000000..e309cd75 --- /dev/null +++ b/cmcd/package.json @@ -0,0 +1,43 @@ +{ + "name": "@theoplayer/cmcd-connector-web", + "version": "1.0.0", + "description": "A connector implementing CMCD support for web.", + "main": "dist/cmcd-connector.umd.js", + "repository": { + "type": "git", + "url": "git+https://github.com/THEOplayer/web-connectors.git", + "directory": "cmcd" + }, + "homepage": "https://theoplayer.com/", + "module": "dist/cmcd-connector.esm.js", + "types": "dist/cmcd-connector.d.ts", + "exports": { + ".": { + "types": "./dist/cmcd-connector.d.ts", + "import": "./dist/cmcd-connector.esm.js", + "require": "./dist/cmcd-connector.umd.js" + }, + "./dist/*": "./dist/*", + "./package": "./package.json", + "./package.json": "./package.json" + }, + "scripts": { + "clean": "rimraf lib dist", + "bundle": "rollup -c rollup.config.mjs", + "build": "npm run clean && npm run bundle", + "serve": "http-server", + "test": "jest" + }, + "author": "THEO Technologies NV", + "license": "MIT", + "files": [ + "dist/", + "CHANGELOG.md", + "README.md", + "LICENSE.md", + "package.json" + ], + "peerDependencies": { + "theoplayer": "^5.0.0 || ^6.0.0" + } +} diff --git a/cmcd/rollup.config.mjs b/cmcd/rollup.config.mjs new file mode 100644 index 00000000..570798e8 --- /dev/null +++ b/cmcd/rollup.config.mjs @@ -0,0 +1,13 @@ +import fs from "node:fs"; +import {getSharedBuildConfiguration} from "../tools/build.mjs"; + +const {version} = JSON.parse(fs.readFileSync("./package.json", "utf8")); + +const fileName = "cmcd-connector"; +const globalName = "THEOplayerCMCDConnector" +const banner = ` +/** + * THEOplayer CMCD Connector v${version} + */`.trim(); + +export default getSharedBuildConfiguration(fileName, globalName, banner); diff --git a/cmcd/src/CMCDCollector.ts b/cmcd/src/CMCDCollector.ts new file mode 100644 index 00000000..3458829f --- /dev/null +++ b/cmcd/src/CMCDCollector.ts @@ -0,0 +1,353 @@ +import { ChromelessPlayer, CurrentSourceChangeEvent, MediaTrack, MediaType, Quality, Request } from 'theoplayer'; +import { CMCDObjectType, CMCDPayload, CMCDReservedKey, CMCDStreamingFormat, CMCDStreamType } from './CMCDPayload'; +import { Configuration } from './Configuration'; +import { calculateBufferSize, getStreamingFormatFromTypedSource } from './PlayerUtils'; +import { uuid } from './RandomUtils'; + +const REQUESTED_MAXIMUM_THROUGHPUT_SAFETY_FACTOR = 5; +const BUFFER_STARVATION_MARGIN = 0.2; + +/** + * Collector for all CMCD data. This object will subscribe to player events and observe player behaviour in order to collect + * the correct data. When resetting the player, this object must be recycled as well. + */ +export class CMCDCollector { + private readonly _config: Configuration; + private readonly _player: ChromelessPlayer; + private _bufferStarved: boolean = false; + private _streamType: CMCDStreamType | undefined; + private _streamingFormat: CMCDStreamingFormat | undefined; + + /** + * Creates a new CMCD data collector for the given player and the given configuration. + * @param player The player for which data is to be collected. + * @param config The configuration containing the sessionID, contentID and optional custom parameters. + */ + constructor(player: ChromelessPlayer, config: Configuration) { + this._player = player; + this._config = config; + player.addEventListener('waiting', this.handleWaiting_); + player.addEventListener('durationchange', this.handleDurationChange_); + player.addEventListener('currentsourcechange', this.handleCurrentSourceChange_); + } + + /** + * Handler for player `waiting` events in order to mark buffer starvation. + * @private + */ + private handleWaiting_ = () => { + if (calculateBufferSize(this._player.currentTime, this._player.buffered) < BUFFER_STARVATION_MARGIN) { + this._bufferStarved = true; + } + }; + + /** + * Handler for player `durationchange` events in order to identify the stream type. + * @private + */ + private handleDurationChange_ = () => { + if (this._player.duration) { + this._streamType = isFinite(this._player.duration) ? CMCDStreamType.STATIC : CMCDStreamType.DYNAMIC; + } else { + this._streamType = undefined; + } + }; + + /** + * Handler for player `currentsourcechange` events in order to identify the streaming format. + * @private + */ + private handleCurrentSourceChange_ = (event: CurrentSourceChangeEvent) => { + const currentSource = event.currentSource; + if (!currentSource) { + this._streamingFormat = undefined; + } else { + this._streamingFormat = getStreamingFormatFromTypedSource(currentSource); + } + }; + + /** + * Collection method for all reserved keys of the SESSION-type and the configured custom keys. + * @returns A payload object containing all custom keys and all reserved keys of the SESSION-type other than VERSION (which currently is 1 so it must be omitted). + * @private + */ + private collectSessionKeys(): CMCDPayload { + let payload: CMCDPayload = { + // [CMCDReservedKey.VERSION]: 1 // Client SHOULD omit this field if the version is 1. + }; + + if (this._config.sessionID) { + payload[CMCDReservedKey.SESSION_ID] = this._config.sessionID; + } + + if (this._config.contentID) { + payload[CMCDReservedKey.CONTENT_ID] = this._config.contentID; + } + + if (this._player.playbackRate !== 1) { + payload[CMCDReservedKey.PLAYBACK_RATE] = this._player.playbackRate; + } + + if (this._streamType) { + payload[CMCDReservedKey.STREAM_TYPE] = this._streamType; + } + + if (this._streamingFormat) { + payload[CMCDReservedKey.STREAMING_FORMAT] = this._streamingFormat; + } + + if (this._config.customKeys) { + payload = { + ...payload, + ...this._config.customKeys + }; + } + + return payload; + } + + /** + * Collection method for all reserved keys of the STATUS-type. + * @returns A payload object containing all reserved keys of the STATUS-type. + * @private + */ + collectStatusKeys(track: MediaTrack | undefined): CMCDPayload { + const payload: CMCDPayload = {}; + + if (this._bufferStarved) { + payload[CMCDReservedKey.BUFFER_STARVATION] = true; + this._bufferStarved = false; + } + + const activeQuality = track?.activeQuality; + const activeBandwidth = activeQuality?.bandwidth; + const targetBuffer = this._player.abr.targetBuffer; + if (activeBandwidth && targetBuffer) { + payload[CMCDReservedKey.REQUESTED_MAXIMUM_THROUGHPUT] = calculateRequestedMaximumThroughput( + this._player.currentTime, + this._player.buffered, + targetBuffer, + this._player.playbackRate, + activeBandwidth + ); + } + + return payload; + } + + /** + * Collects all the CMCD parameters as supported by the connector depending on the provided {@link Request}. + * @param request The request for which CMCD parameters should be collected. + * @returns A payload object containing keys for the provided request. + */ + collect(request: Request): CMCDPayload { + const sessionKeys = this.collectSessionKeys(); + const requestKeys = this._config.sendRequestID + ? { + [CMCDReservedKey.SVA_REQUEST_ID]: uuid() + } + : {}; + switch (request.type) { + case 'manifest': { + return { + ...sessionKeys, + ...requestKeys, + [CMCDReservedKey.OBJECT_TYPE]: CMCDObjectType.INIT_SEGMENT + }; + } + case 'content-protection': { + return { + ...sessionKeys, + ...requestKeys, + [CMCDReservedKey.OBJECT_TYPE]: CMCDObjectType.KEY_LICENSE_OR_CERTIFICATE + }; + } + case 'segment': + if (request.subType === 'initialization-segment') { + return { + ...sessionKeys, + ...requestKeys, + [CMCDReservedKey.STARTUP]: true, + [CMCDReservedKey.OBJECT_TYPE]: CMCDObjectType.INIT_SEGMENT + }; + } + + const bufferSize = calculateBufferSize(this._player.currentTime, this._player.buffered); + // TODO could be better if we have segment info... + const track = + (request.mediaType === 'audio' && this._player.audioTracks[0]) || + (request.mediaType === 'video' && this._player.videoTracks[0]) || + undefined; + + const statusKeys = this.collectStatusKeys(track); + const payload: CMCDPayload = { + ...sessionKeys, + ...statusKeys, + ...requestKeys, + [CMCDReservedKey.OBJECT_TYPE]: getObjectType(request.mediaType) // TODO could be better if we have segment info... + }; + + if (track?.activeQuality) { + // Encoded bitrate in kbps + if (track.activeQuality.bandwidth) { + payload[CMCDReservedKey.ENCODED_BITRATE] = Math.round(track.activeQuality.bandwidth / 1000); + } + + const playableQualities: Quality[][] = track.qualities + .slice() + .sort(perceptualQualityCompareFunction) + .filter(isPlayableFilter.bind(null, request.mediaType)) + .reduce(redundantQualityGrouper, []); + payload[CMCDReservedKey.SVA_PLAYABLE_MANIFEST_INDEX] = playableQualities.length; + payload[CMCDReservedKey.SVA_CURRENT_MANIFEST_INDEX] = + findQualityGroupIndex(playableQualities, track.activeQuality) + 1; + + payload[CMCDReservedKey.SVA_TRACK_IDENTIFIER] = track.id ?? track.uid; + } + + // Top bitrate in kbps + if (track?.qualities) { + const topQuality = track.qualities[track.qualities.length - 1]; + payload[CMCDReservedKey.TOP_BITRATE] = Math.round(topQuality.bandwidth / 1000); + } + + // Measured throughput rounded to the closest 100kbps in kbps + // NOTE only the LL-HLS pipeline realy uses the estimator properly today, so fall back to metrics API for others + const measuredBandwidth = + this._player.network.estimator.bandwidth || this._player.metrics.currentBandwidthEstimate; + const measuredBandwidthInKbps = measuredBandwidth / 1000; + payload[CMCDReservedKey.MEASURED_THROUGHPUT] = (measuredBandwidthInKbps / 100 + 1) * 100; + + // Deadline rounded to the closest 100ms in ms. + payload[CMCDReservedKey.DEADLINE] = Math.round((bufferSize / this._player.playbackRate) * 10) * 100; + + // Buffer length rounded to the closest 100ms in ms. + payload[CMCDReservedKey.BUFFER_LENGTH] = Math.round(bufferSize * 10) * 100; + + // Startup flag if buffer is empty due to startup, seeking or starvation. + if (bufferSize < BUFFER_STARVATION_MARGIN) { + payload[CMCDReservedKey.STARTUP] = true; + if (this._player.played.length === 0) { + delete payload[CMCDReservedKey.BUFFER_STARVATION]; + } + } + + // TODO object duration + // TODO next request... and next range + return payload; + default: + return {}; + } + } + + /** + * Destruction method responsible to clear up any event listeners. + */ + destroy(): void { + this._player.removeEventListener('waiting', this.handleWaiting_); + this._player.removeEventListener('durationchange', this.handleDurationChange_); + this._player.removeEventListener('currentsourcechange', this.handleCurrentSourceChange_); + } +} + +/** + * Maps the provided {@link MediaType} to the corresponding {@link CMCDObjectType}. + * @param mediaType The media type for which the object type should be retrieved. + */ +function getObjectType(mediaType: MediaType): CMCDObjectType { + switch (mediaType) { + case 'audio': + return CMCDObjectType.AUDIO; + case 'video': + return CMCDObjectType.VIDEO; + case 'text': + return CMCDObjectType.CAPTION_OR_SUBTITLE; + case 'image': + default: + return CMCDObjectType.OTHER; + } +} + +/** + * Calculates the maximum throughput which should be provided. This throughput is calculated as the maximum between the current + * data consumption rate and the rate needed to achieve the target buffer size in a timely fashion. The resulting value is multiplied + * with {@link REQUESTED_MAXIMUM_THROUGHPUT_SAFETY_FACTOR} in order to ensure a high enough bandwidth is marked. + * The resulting value is returned rounded up to the next 100kbps in kbps. + * @param currentTime The current time which is used to calculate the current buffer size. + * @param buffered The time ranges used to calculate the current buffer size. + * @param targetBuffer The target buffer which is to be achieved. + * @param playbackRate The rate at which the player is currently playing. + * @param activeBandwidth The bandwidth for which the player is currently downloading data. + */ +function calculateRequestedMaximumThroughput( + currentTime: number, + buffered: TimeRanges, + targetBuffer: number, + playbackRate: number, + activeBandwidth: number +) { + const bufferSize = calculateBufferSize(currentTime, buffered); + const dataConsumptionRate = activeBandwidth * playbackRate; + const dataRequiredToReachTargetBuffer = activeBandwidth * (targetBuffer - bufferSize); + const minimumBandwidth = Math.max(dataConsumptionRate, dataRequiredToReachTargetBuffer); + const requestedMaximumThroughput = minimumBandwidth * REQUESTED_MAXIMUM_THROUGHPUT_SAFETY_FACTOR; + const requestedMaximumThroughputInKbps = requestedMaximumThroughput / 1000; + return (requestedMaximumThroughputInKbps / 100 + 1) * 100; +} + +/** + * Compare function which compares two qualities based on their assumed perceptual quality as based on the available + * bitrate. + * @param first The first element for this comparison. + * @param second The second element for this comparison. + */ +function perceptualQualityCompareFunction(first: Quality | undefined, second: Quality): number { + return (first?.bandwidth ?? 0) - second.bandwidth; +} + +/** + * Filter returning true if the quality provided can be played if it's from the given type and false otherwise. + * @param type The media type of the quality. + * @param quality The quality which we want to test. + */ +function isPlayableFilter(type: MediaType, quality: Quality): boolean { + switch (type) { + case 'audio': + return MediaSource.isTypeSupported(`audio/mp4; codecs="${quality.codecs}"`); + case 'video': + return MediaSource.isTypeSupported(`video/mp4; codecs="${quality.codecs}"`); + default: + return true; + } +} + +/** + * Reducer which can be used to reduce a list of qualities into a list of qualities grouped based on perceptual quality. + * Assumes the list being looped is sorted by perceptual quality and an empty list is provided on the first run. + * @param list The current list of already grouped qualities. + * @param quality The quality to group. + */ +function redundantQualityGrouper(list: Quality[][], quality: Quality): Quality[][] { + const lastGroup: Quality[] | undefined = list[list.length - 1]; + const lastGroupElement: Quality | undefined = lastGroup?.[0]; + if (lastGroup && perceptualQualityCompareFunction(lastGroupElement, quality) === 0) { + lastGroup.push(quality); + } else { + list.push([quality]); + } + return list; +} + +/** + * Returns the zero-based index of the group to which the provided quality belongs. Behaves similar to `Array.indexOf`. + * @param qualityGroups The list of quality groups in which we try to find the index. + * @param quality The quality for which the group index is sought. + */ +function findQualityGroupIndex(qualityGroups: Quality[][], quality: Quality): number { + for (let index = 0; index < qualityGroups.length; index += 1) { + if (qualityGroups[index].indexOf(quality) !== -1) { + return index; + } + } + return -1; +} diff --git a/cmcd/src/CMCDConnector.ts b/cmcd/src/CMCDConnector.ts new file mode 100644 index 00000000..c4104a8a --- /dev/null +++ b/cmcd/src/CMCDConnector.ts @@ -0,0 +1,125 @@ +import { ChromelessPlayer, InterceptableRequest, Request } from 'theoplayer'; +import { CMCDCollector } from './CMCDCollector'; +import { CMCDPayload } from './CMCDPayload'; +import { Configuration, TransmissionMode } from './Configuration'; +import { uuid } from './RandomUtils'; +import { + createTransmissionModeStrategyFor, + TransmissionModeStrategy +} from './TransmissionModeStrategies/TransmissionModeStrategy'; + +/** + * Type for processors which are triggered before a CMCD payload is transmitted. + * The `payload` parameter contains the actual payload which will be transmitted. + * The `request` parameter contains the request for which the payload was generated. Note this is before any addition of + * payload data if it will be piggy backing on this request. + * The returned payload should be the payload which will be transmitted. + */ +export type CMCDPayloadProcessor = (payload: CMCDPayload, request: Request) => CMCDPayload; + +/** + * The connector between a THEOplayer Player instance and a Common Media Client Data (CMCD) server. + * This implementation supports CMCD data as defined in CTA-5004, published in September 2020. + * + * Note that when native playback is being used, either through THEOplayer's configuration, or due to absence of MSE/EME APIs + * (such as on iOS Safari), the {@link TransmissionMode.JSON_OBJECT} should be used. + * + * All standardized reserved keys are reported, except: + * - Object duration (d) + * - Next object request (nor) + * - Next range request (nrr) + */ +export class CMCDConnector { + private _transmissionModeStrategy: TransmissionModeStrategy | undefined; + private _collector: CMCDCollector | undefined; + private readonly _player: ChromelessPlayer; + private _processor: CMCDPayloadProcessor | undefined; + private readonly _sessionID: string; + + /** + * Creates a new connector for the given player, following the provided configuration. If no session ID is provided, + * the session ID will be set to a random UUIDv4. + * @param player The THEOplayer.Player instance for which common media client data is to be reported. + * @param configuration The {@link Configuration} detailing how the data is to be logged. When no configuration is provided, + * the {@link TransmissionMode.QUERY_ARGUMENT} transmission mode will be used in order to avoid CORS preflight requests in browsers. + */ + constructor(player: ChromelessPlayer, configuration?: Configuration) { + this._player = player; + this._sessionID = configuration?.sessionID || uuid(); + this.reconfigure(configuration); + player.network.addRequestInterceptor(this.interceptor_); + player.addEventListener('destroy', this.onDestroy_); + } + + /** + * The interceptor which ensures a payload is constructed for every request which should be fired. + * + * @param request The request which is fired. + */ + private interceptor_ = (request: InterceptableRequest) => { + if (this._collector === undefined || this._transmissionModeStrategy === undefined) { + return; + } + + let payload = this._collector.collect(request); + if (this._processor) { + payload = this._processor(payload, request); + } + this._transmissionModeStrategy.transmitPayload(request, payload); // TODO add callback/processor + }; + + /** + * Handler to observe player destruction and automatically destroy the connector and all created objects. + */ + private onDestroy_ = () => { + this.destroy(); + }; + + /** + * Resets the configuration of the connector. The connector will halt all transmissions and new transmissions will be + * made as per the updated configuration. If no new session ID is provided, the previous session ID will be reused. + * @param configuration The {@link Configuration} detailing how the data is to be logged. When no configuration is provided, + * the {@link TransmissionMode.QUERY_ARGUMENT} transmission mode will be used in order to avoid CORS preflight requests in browsers. + */ + reconfigure(configuration?: Configuration): void { + const config: Configuration = { + transmissionMode: TransmissionMode.QUERY_ARGUMENT, + sessionID: this._sessionID, + ...configuration + }; + this._collector?.destroy(); + this._collector = new CMCDCollector(this._player, config); + this._transmissionModeStrategy = createTransmissionModeStrategyFor(config); + } + + /** + * Returns the current processor which will be called before transmitting any CMCD payload, or undefined if no + * processor is known. + */ + get processor(): CMCDPayloadProcessor | undefined { + return this._processor; + } + + /** + * Modifies the current processor which will be called before transmitting any CMCD payload data. This value can be + * set to `undefined` to remove the current processor. + * @param value The processor which must be used for any subsequent payload about to be transmitted or `undefined` if + * no processor is to be used. + */ + set processor(value: CMCDPayloadProcessor | undefined) { + this._processor = value; + } + + /** + * Stops all operation of the connector. + */ + destroy(): void { + this._player.removeEventListener('destroy', this.onDestroy_); + this._player.network.removeRequestInterceptor(this.interceptor_); + this._collector?.destroy(); + } +} + +export function createCMCDConnector(player: ChromelessPlayer, configuration?: Configuration): CMCDConnector { + return new CMCDConnector(player, configuration); +} diff --git a/cmcd/src/CMCDPayload.ts b/cmcd/src/CMCDPayload.ts new file mode 100644 index 00000000..3d96c902 --- /dev/null +++ b/cmcd/src/CMCDPayload.ts @@ -0,0 +1,290 @@ +/** + * The definition of payload object containing all CMCD data. + * Note custom keys MUST carry a hyphenated prefix to ensure that there will not be a namespace collision with future + * revisions to the specification. Clients SHOULD use a reverse-DNS syntax when defining their own prefix. + */ +export type CMCDPayload = { + [CMCDReservedKey.ENCODED_BITRATE]?: number; + [CMCDReservedKey.BUFFER_LENGTH]?: number; + [CMCDReservedKey.BUFFER_STARVATION]?: boolean; + [CMCDReservedKey.CONTENT_ID]?: string; + [CMCDReservedKey.OBJECT_DURATION]?: number; + [CMCDReservedKey.DEADLINE]?: number; + [CMCDReservedKey.MEASURED_THROUGHPUT]?: number; + [CMCDReservedKey.NEXT_OBJECT_REQUEST]?: string; + [CMCDReservedKey.NEXT_RANGE_REQUEST]?: string; + [CMCDReservedKey.OBJECT_TYPE]?: CMCDObjectType; + [CMCDReservedKey.PLAYBACK_RATE]?: number; + [CMCDReservedKey.REQUESTED_MAXIMUM_THROUGHPUT]?: number; + [CMCDReservedKey.STREAMING_FORMAT]?: CMCDStreamingFormat; + [CMCDReservedKey.SESSION_ID]?: string; + [CMCDReservedKey.STREAM_TYPE]?: CMCDStreamType; + [CMCDReservedKey.STARTUP]?: boolean; + [CMCDReservedKey.TOP_BITRATE]?: number; + [CMCDReservedKey.VERSION]?: number; + + [CMCDReservedKey.SVA_REQUEST_ID]?: string; + [CMCDReservedKey.SVA_CURRENT_MANIFEST_INDEX]?: number; + [CMCDReservedKey.SVA_PLAYABLE_MANIFEST_INDEX]?: number; + [CMCDReservedKey.SVA_TRACK_IDENTIFIER]?: string; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [customKey: string]: any; +}; + +/** + * The possible header names as defined in the CTA-5004 specification. + */ +export enum CMCDHeaderName { + CMCD_OBJECT = 'CMCD-Object', + CMCD_REQUEST = 'CMCD-Request', + CMCD_STATUS = 'CMCD-Status', + CMCD_SESSION = 'CMCD-Session' +} + +/** + * The reserved keys as defined in the CTA-5004 specification. + */ +export enum CMCDReservedKey { + /** + * 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. + * + * Exposed as an Integer in kbps. + */ + ENCODED_BITRATE = 'br', + /** + * 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’. + * + * Exposed as an Integer in ms. + */ + BUFFER_LENGTH = 'bl', + /** + * 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. + */ + BUFFER_STARVATION = 'bs', + /** + * 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. + */ + CONTENT_ID = 'cid', + /** + * 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. + * + * Exposed as an Integer in ms. + */ + OBJECT_DURATION = 'd', + /** + * 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. + * + * Exposed as an Integer in ms. + */ + DEADLINE = 'dl', + /** + * 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. + * + * Exposed as an Integer in kbps. + */ + MEASURED_THROUGHPUT = 'mtp', + /** + * 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. + */ + NEXT_OBJECT_REQUEST = 'nor', + /** + * 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: + * "-" + * "-" + * "-" + */ + NEXT_RANGE_REQUEST = 'nrr', + /** + * The media type of the current object being requested. + * If the object type being requested is unknown, then this key MUST NOT be used. + */ + OBJECT_TYPE = 'ot', + /** + * The playback rate at which is currently being played. + * 1 if real-time, + * 2 if double speed, + * 0 if not playing. + * SHOULD only be sent if not equal to 1. + */ + PLAYBACK_RATE = 'pr', + /** + * 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. + * + * Exposed as an Integer in kbps. + */ + REQUESTED_MAXIMUM_THROUGHPUT = 'rtp', + /** + * The streaming format which defines the current request + * If the streaming format being requested is unknown, then this key MUST NOT be used. + */ + STREAMING_FORMAT = 'sf', + /** + * 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. + */ + SESSION_ID = 'sid', + /** + * The type of stream being played. + */ + STREAM_TYPE = 'st', + /** + * 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. + */ + STARTUP = 'su', + /** + * The highest bitrate rendition in the manifest or playlist that the client is allowed to play, given current codec, + * licensing and sizing constraints. + * + * Exposed as an Integer in kbps. + */ + TOP_BITRATE = 'tb', + /** + * 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. + */ + VERSION = 'v', + + /** + * A GUID identifying the current request. Every request will automatically receive a new GUID automatically. + * Maximum length is 64 characters. It is RECOMMENDED to conform to the UUID specification. + * + * Note: This is NOT a part of the CMCD specification as is, but is an additional parameter in light with the work done in the SVA. + */ + SVA_REQUEST_ID = 'org.svalabs-rid', + /** + * The 1-based index of the CMAF Track, of which the currently loading object is a part, in the sorted list of all + * tracks in the associated Aligned CMAF Switching Set. + * This list is sorted as follows: + * - In ascending order of the protocol's perceptual quality score (if available), + * - In ascending order of the Track's bandwidth, + * - In ascending order of the order as present in the stream Manifests. + * + * Note: This is NOT a part of the CMCD specification as is, but is an additional parameter in light with the work done in the SVA. + */ + SVA_CURRENT_MANIFEST_INDEX = 'org.svalabs-cmi', + /** + * The number of CMAF Tracks, of which the currently loading object is a part, in the sorted list of all + * tracks in the associated Aligned CMAF Switching Set. + * + * Note: This is NOT a part of the CMCD specification as is, but is an additional parameter in light with the work done in the SVA. + */ + SVA_PLAYABLE_MANIFEST_INDEX = 'org.svalabs-pmi', + /** + * The identifier of the Aligned CMAF Switching Set to which the currently loading object belongs. Its value must be unique + * across the current viewer session for all different Aligned CMAF Switching Sets and should (if available) be the identifier + * in the CMAF Manifest. + * + * Note: This is NOT a part of the CMCD specification as is, but is an additional parameter in light with the work done in the SVA. + */ + SVA_TRACK_IDENTIFIER = 'org.svalabs-tid' +} + +/** + * The Object Type as specified in Table 1. + */ +export enum CMCDObjectType { + /** + * Audio only + */ + AUDIO = 'a', // note there is 'm' as an alternative + /** + * Video only + */ + VIDEO = 'v', + /** + * Muxed audio and video + */ + MUXED_AUDIO_VIDEO = 'av', + /** + * Init segment + */ + INIT_SEGMENT = 'i', + /** + * Caption or subtitle + */ + CAPTION_OR_SUBTITLE = 'c', + /** + * ISOBMFF timed text track + */ + TIMED_TEXT_TRACK = 'tt', + /** + * Cryptographic key, license or certificate + */ + KEY_LICENSE_OR_CERTIFICATE = 'k', + /** + * Other (not unknown) + */ + OTHER = 'o' +} + +/** + * The Streaming Format as specified in Table 1. + */ +export enum CMCDStreamingFormat { + /** + * MPEG-DASH streaming + */ + MPEG_DASH = 'd', + /** + * HTTP Live Streaming (HLS) + */ + HLS = 'h', + /** + * Microsoft Smooth Streaming + */ + SMOOTH = 's', + /** + * Other (not unknown). + */ + OTHER = 'o' +} + +/** + * The Stream Type as specified in Table 1. + */ +export enum CMCDStreamType { + /** + * All segments are available e.g. VOD + */ + STATIC = 'v', + /** + * Segments become available over time e.g. LIVE + */ + DYNAMIC = 'l' +} diff --git a/cmcd/src/CMCDPayloadUtils.ts b/cmcd/src/CMCDPayloadUtils.ts new file mode 100644 index 00000000..a66df287 --- /dev/null +++ b/cmcd/src/CMCDPayloadUtils.ts @@ -0,0 +1,91 @@ +import { CMCDHeaderName, CMCDPayload, CMCDReservedKey } from './CMCDPayload'; + +/** + * Transforms the provided payload into a query parameter string. Strings will be escaped and placed between quotes, numbers + * will be passed as raw numbers, booleans will be shortened and tokens will be transformed as per the specification. + * The resulting list of parameters will be sorted ascending based on UTF-16 values of the keys. + * @param payload The payload. + * @returns A query parameter string containing all payload parameters as specified. + */ +export function transformToQueryParameters(payload: CMCDPayload): string { + const serialisedEntries: string[] = []; + for (const key in payload) { + if (Object.prototype.hasOwnProperty.call(payload, key)) { + const value = payload[key]; + if (typeof value === 'boolean') { + if (value) { + serialisedEntries.push(key); + } else { + serialisedEntries.push(`${key}=false`); + } + } else if (typeof value === 'number') { + serialisedEntries.push(`${key}=${value}`); + } else { + serialisedEntries.push( + `${key}=${JSON.stringify( + encodeURIComponent(payload[key].replace(/\\/g, '\\\\').replace(/"/g, '\\"')) + )}` + ); + } + } + } + serialisedEntries.sort(); + return serialisedEntries.join(','); +} + +/** + * Provides a map of all reserved keys to the header name. + * @param key The key for which you want to know the header name. + * @returns The header name in which the key should be sent. + */ +function mapKeyToHeaderMapping(key: string): CMCDHeaderName { + switch (key) { + case CMCDReservedKey.ENCODED_BITRATE: + case CMCDReservedKey.OBJECT_DURATION: + case CMCDReservedKey.TOP_BITRATE: + return CMCDHeaderName.CMCD_OBJECT; + case CMCDReservedKey.BUFFER_LENGTH: + case CMCDReservedKey.DEADLINE: + case CMCDReservedKey.MEASURED_THROUGHPUT: + case CMCDReservedKey.NEXT_OBJECT_REQUEST: + case CMCDReservedKey.NEXT_RANGE_REQUEST: + case CMCDReservedKey.OBJECT_TYPE: + case CMCDReservedKey.SVA_REQUEST_ID: + case CMCDReservedKey.SVA_TRACK_IDENTIFIER: + case CMCDReservedKey.SVA_PLAYABLE_MANIFEST_INDEX: + case CMCDReservedKey.SVA_CURRENT_MANIFEST_INDEX: + case CMCDReservedKey.STARTUP: + return CMCDHeaderName.CMCD_REQUEST; + case CMCDReservedKey.BUFFER_STARVATION: + case CMCDReservedKey.REQUESTED_MAXIMUM_THROUGHPUT: + return CMCDHeaderName.CMCD_STATUS; + case CMCDReservedKey.CONTENT_ID: + case CMCDReservedKey.PLAYBACK_RATE: + case CMCDReservedKey.STREAMING_FORMAT: + case CMCDReservedKey.SESSION_ID: + case CMCDReservedKey.STREAM_TYPE: + case CMCDReservedKey.VERSION: + default: + return CMCDHeaderName.CMCD_SESSION; + } +} + +/** + * Returns a new {@link CMCDPayload} which contains all the keys from the provided payload but only if these keys + * should be sent with the provided header. + * @param payload The payload. + * @param header The header for which payload entries must be retained. + * @returns A payload containing only the key/value pairs for the provided header. + */ +export function extractKeysFor(payload: CMCDPayload, header: CMCDHeaderName): CMCDPayload { + const filteredPayload: CMCDPayload = {}; + for (const key in payload) { + if (Object.prototype.hasOwnProperty.call(payload, key)) { + const headerForKey = mapKeyToHeaderMapping(key); + if (headerForKey === header) { + filteredPayload[key] = payload[key]; + } + } + } + return filteredPayload; +} diff --git a/cmcd/src/Configuration.ts b/cmcd/src/Configuration.ts new file mode 100644 index 00000000..dc77b068 --- /dev/null +++ b/cmcd/src/Configuration.ts @@ -0,0 +1,66 @@ +/** + * The transmission mode to be used. + */ +export enum TransmissionMode { + /** + * Transmit CMCD data as a custom HTTP request header. + * + * @remarks + * + * Usage of a custom header from a web browser user-agent will trigger a preflight OPTIONS request before + * each unique media object request. This will lead to an increased request rate against the server. As a result, + * for CMCD transmissions from web browser user-agents that require CORS-preflighting per URL, the preferred mode + * of use is query arguments. + */ + HTTP_HEADER, + /** + * Transmit CMCD data as a HTTP query argument. + */ + QUERY_ARGUMENT, + /** + * Transmit CMCD data as a JSON object independent of the HTTP object request. + */ + JSON_OBJECT +} + +/** + * The configuration object for the Common Media Client Data (CTA-5004) connector. + */ +export interface Configuration { + /** + * The data transmission mode as defined in section 2 of the specification. + * When no transmission mode is selected, {@link TransmissionMode.QUERY_ARGUMENT} will be used in order to avoid CORS preflight requests in browsers. + */ + transmissionMode: TransmissionMode; + + /** + * The target URI where client data is to be delivered in case the {@link Configuration.transmissionMode} is set + * to {@link TransmissionMode.JSON_OBJECT}. + */ + jsonObjectTargetURI?: string; + + /** + * The session ID parameter which should be passed as a CMCD value. If left empty, a UUIDv4 will be generated when applying the configuration. + */ + sessionID?: string; + + /** + * The content ID parameter which should be passed as a CMCD value. If left empty, no content ID will be sent. + */ + contentID?: string; + + /** + * A flag to indicate if request IDs should be sent or not. + * When set to a truthly value, a UUIDv4 will be sent as a request id (`rid`) with every request to allow for request tracing. + */ + sendRequestID?: boolean; + + /** + * An object containing custom keys which should be added to the generated CMCD parameters. + * Note custom keys MUST carry a hyphenated prefix to ensure that there will not be a namespace collision with future + * revisions to the specification. Clients SHOULD use a reverse-DNS syntax when defining their own prefix. + */ + customKeys?: { + [key: string]: string | number | boolean; + }; +} diff --git a/cmcd/src/PlayerUtils.ts b/cmcd/src/PlayerUtils.ts new file mode 100644 index 00000000..be9fb21d --- /dev/null +++ b/cmcd/src/PlayerUtils.ts @@ -0,0 +1,62 @@ +import { TypedSource } from 'theoplayer'; +import { CMCDStreamingFormat } from './CMCDPayload'; + +function isM3U8SourceString(source: string): boolean { + return source.indexOf('m3u8') !== -1; +} + +function isDASHSourceString(source: string): boolean { + return source.indexOf('mpd') !== -1; +} + +function guessStreamingFormatFromURI(uri: string | undefined): CMCDStreamingFormat { + if (!uri) { + return CMCDStreamingFormat.OTHER; + } + if (isDASHSourceString(uri)) { + return CMCDStreamingFormat.MPEG_DASH; + } + if (isM3U8SourceString(uri)) { + return CMCDStreamingFormat.HLS; + } + return CMCDStreamingFormat.OTHER; +} + +/** + * Returns the streaming format as observed in the provided {@link TypedSource}. If no {@link TypedSource.type} is provided, + * it will be inferred from the {@link TypedSource.src}, or the {@link CMCDStreamingFormat.OTHER} will be returned. + * @param source The source for which the {@link CMCDStreamingFormat} is to be inferred. + * @returns The streaming format for the provided source. + */ +export function getStreamingFormatFromTypedSource(source: TypedSource): CMCDStreamingFormat { + const type = source.type; + switch (type) { + case 'application/dash+xml': + return CMCDStreamingFormat.MPEG_DASH; + case 'application/vnd.apple.mpegurl': + case 'application/x-mpegurl': + return CMCDStreamingFormat.HLS; + default: + return guessStreamingFormatFromURI(source.src); + } +} + +const BUFFER_GAP_OFFSET = 0.05; + +/** + * Returns the size of the buffer in seconds as of the provided time in the given ranges. Will jump gaps if small enough. + * @param currentTime The time as of which the buffer size is to be calculated. + * @param buffered The time range which is to be used containing ranges which are within the buffer. + * @returns The buffer size in seconds. + */ +export function calculateBufferSize(currentTime: number, buffered: TimeRanges): number { + let timeIndex = currentTime; + for (let bufferIndex = 0; bufferIndex < buffered.length; bufferIndex += 1) { + const rangeStart = buffered.start(bufferIndex); + const rangeEnd = buffered.end(bufferIndex); + if (timeIndex + BUFFER_GAP_OFFSET >= rangeStart && timeIndex - BUFFER_GAP_OFFSET <= rangeEnd) { + timeIndex = rangeEnd; + } + } + return timeIndex - currentTime; +} diff --git a/cmcd/src/RandomUtils.ts b/cmcd/src/RandomUtils.ts new file mode 100644 index 00000000..492e69b5 --- /dev/null +++ b/cmcd/src/RandomUtils.ts @@ -0,0 +1,14 @@ +const randomGenerator: () => number = () => + crypto ? crypto.getRandomValues(new Uint8Array(1))[0] : Math.random() * 255; + +/** + * Helper function to retrieve a random UUID. + * @returns A randomly generated UUIDv4. + */ +export function uuid(): string { + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: string) => { + const i: number = Number(c); + // eslint-disable-next-line no-bitwise + return (i ^ (randomGenerator() & (15 >> (i / 4)))).toString(16); + }); +} diff --git a/cmcd/src/TransmissionModeStrategies/HTTPHeaderTransmissionModeStrategy.ts b/cmcd/src/TransmissionModeStrategies/HTTPHeaderTransmissionModeStrategy.ts new file mode 100644 index 00000000..149fa2d0 --- /dev/null +++ b/cmcd/src/TransmissionModeStrategies/HTTPHeaderTransmissionModeStrategy.ts @@ -0,0 +1,37 @@ +import { InterceptableRequest } from 'theoplayer'; +import { CMCDHeaderName, CMCDPayload } from '../CMCDPayload'; +import { extractKeysFor, transformToQueryParameters } from '../CMCDPayloadUtils'; +import { TransmissionModeStrategy } from './TransmissionModeStrategy'; + +/** + * The transmission mode strategy to transmit CMCD data as HTTP Headers as specified in section 2.1 of CTA-5004. + * This strategy will add CMCD headers prefixed with `CMCD-` based on their expected level of variability: + * - CMCD-Request: keys whose values vary with each request. + * - CMCD-Object: keys whose values vary with the object being requested. + * - CMCD-Status: keys whose values do not vary with every request or object. + * - CMCD-Session: keys whose values are expected to be invariant over the life of the session. + * Note the addition of these headers will trigger CORS pre-flight requests in most web based environments. + */ +export class HTTPHeaderTransmissionModeStrategy implements TransmissionModeStrategy { + /** + * The method responsible to transmit the CMCD payload for the provided request. + * This strategy piggybacks on the provided request and will add the relevant CMCD headers by redirecting the original request. + * @param request The request for which the CMCD payload was generated. + * @param payload The payload which should be sent. + */ + transmitPayload(request: InterceptableRequest, payload: CMCDPayload): void { + const headers = request.headers; + + for (const headerName of Object.values(CMCDHeaderName)) { + const parameters = transformToQueryParameters(extractKeysFor(payload, headerName)); + if (parameters) { + headers[headerName] = parameters; + } + } + + request.redirect({ + ...request, + headers + }); + } +} diff --git a/cmcd/src/TransmissionModeStrategies/JSONObjectTransmissionModeStrategy.ts b/cmcd/src/TransmissionModeStrategies/JSONObjectTransmissionModeStrategy.ts new file mode 100644 index 00000000..2bc9e2ea --- /dev/null +++ b/cmcd/src/TransmissionModeStrategies/JSONObjectTransmissionModeStrategy.ts @@ -0,0 +1,30 @@ +import { InterceptableRequest } from 'theoplayer'; +import { CMCDPayload } from '../CMCDPayload'; +import { TransmissionModeStrategy } from './TransmissionModeStrategy'; + +/** + * The transmission mode strategy to transmit CMCD data as a JSON object to an alternative endpoint as specified in section 2.3 of CTA-5004. + * This means the {@link CMCDPayload} will be sent as the body of an HTTP POST body as a plain JSON object for the configured URI. + */ +export class JSONObjectTransmissionModeStrategy implements TransmissionModeStrategy { + private readonly _uri: string; + + /** + * Creates a new instance and ensures configuration of the provided endpoint. + * @param uri The endpoint to which any payload is to be sent. + */ + constructor(uri: string) { + this._uri = uri; + } + + /** + * The method responsible to transmit the CMCD payload for the provided request. + * This strategy will send out a beacon containing a JSON representation of the payload to the configured URI. + * @param request The request for which the CMCD payload was generated. + * @param payload The payload which should be sent. + */ + transmitPayload(request: InterceptableRequest, payload: CMCDPayload): void { + const data = JSON.stringify(payload); + navigator.sendBeacon(this._uri, data); + } +} diff --git a/cmcd/src/TransmissionModeStrategies/QueryArgumentTransmissionModeStrategy.ts b/cmcd/src/TransmissionModeStrategies/QueryArgumentTransmissionModeStrategy.ts new file mode 100644 index 00000000..7555d25d --- /dev/null +++ b/cmcd/src/TransmissionModeStrategies/QueryArgumentTransmissionModeStrategy.ts @@ -0,0 +1,29 @@ +import { InterceptableRequest } from 'theoplayer'; +import { CMCDPayload } from '../CMCDPayload'; +import { transformToQueryParameters } from '../CMCDPayloadUtils'; +import { TransmissionModeStrategy } from './TransmissionModeStrategy'; + +/** + * The transmission mode strategy to transmit CMCD data as query arguments as specified in section 2.2 of CTA-5004. + * This strategy will append a `CMCD` parameter containing the url encoded concatenation of all key value pairs. + */ +export class QueryArgumentTransmissionModeStrategy implements TransmissionModeStrategy { + /** + * The method responsible to transmit the CMCD payload for the provided request. + * This strategy piggybacks on the provided request and will add a `CMCD` parameter to the query string by redirecting + * the original request. + * @param request The request for which the CMCD payload was generated. + * @param payload The payload which should be sent. + */ + transmitPayload(request: InterceptableRequest, payload: CMCDPayload): void { + const url = new URL(request.url); + const parameters = transformToQueryParameters(payload); + if (parameters) { + url.searchParams.append('CMCD', parameters); + request.redirect({ + ...request, + url: url.href + }); + } + } +} diff --git a/cmcd/src/TransmissionModeStrategies/TransmissionModeStrategy.ts b/cmcd/src/TransmissionModeStrategies/TransmissionModeStrategy.ts new file mode 100644 index 00000000..b5894a36 --- /dev/null +++ b/cmcd/src/TransmissionModeStrategies/TransmissionModeStrategy.ts @@ -0,0 +1,41 @@ +import { InterceptableRequest } from 'theoplayer'; +import { CMCDPayload } from '../CMCDPayload'; +import { Configuration, TransmissionMode } from '../Configuration'; +import { HTTPHeaderTransmissionModeStrategy } from './HTTPHeaderTransmissionModeStrategy'; +import { JSONObjectTransmissionModeStrategy } from './JSONObjectTransmissionModeStrategy'; +import { QueryArgumentTransmissionModeStrategy } from './QueryArgumentTransmissionModeStrategy'; + +/** + * The main interface for all transmission modes. + */ +export interface TransmissionModeStrategy { + /** + * The method responsible to transmit the CMCD payload for the provided request. The strategy can either piggyback + * on the provided request, or can issue a parallel request. + * @param request The request for which the CMCD payload was generated. + * @param payload The payload which should be sent. + */ + transmitPayload(request: InterceptableRequest, payload: CMCDPayload): void; +} + +/** + * Factory function to create the appropriate {@link TransmissionModeStrategy}. + * @param configuration The configuration of the connector containing information on the proper instantiation of the transmission mode. + * @throws Error when {@link TransmissionMode.JSON_OBJECT} is to be used, but no {@link Configuration.jsonObjectTargetURI} is provided. + */ +export function createTransmissionModeStrategyFor(configuration: Configuration): TransmissionModeStrategy { + switch (configuration.transmissionMode) { + case TransmissionMode.HTTP_HEADER: + return new HTTPHeaderTransmissionModeStrategy(); + case TransmissionMode.JSON_OBJECT: + if (!configuration.jsonObjectTargetURI) { + throw new Error( + 'When using the `TransmissionMode.JSON_OBJECT` transmission mode, a `jsonObjectTargetURI` must be provided.' + ); + } + return new JSONObjectTransmissionModeStrategy(configuration.jsonObjectTargetURI); + case TransmissionMode.QUERY_ARGUMENT: + default: + return new QueryArgumentTransmissionModeStrategy(); + } +} diff --git a/cmcd/src/index.ts b/cmcd/src/index.ts new file mode 100644 index 00000000..3faa1eef --- /dev/null +++ b/cmcd/src/index.ts @@ -0,0 +1,15 @@ +import { CMCDObjectType, CMCDPayload, CMCDReservedKey, CMCDStreamingFormat, CMCDStreamType } from './CMCDPayload'; +import { TransmissionMode } from './Configuration'; +import { CMCDConnector, CMCDPayloadProcessor, createCMCDConnector } from './CMCDConnector'; + +export { + CMCDConnector, + createCMCDConnector, + CMCDPayloadProcessor, + TransmissionMode, + CMCDPayload, + CMCDReservedKey, + CMCDStreamingFormat, + CMCDObjectType, + CMCDStreamType +}; diff --git a/cmcd/test/pages/main_esm.html b/cmcd/test/pages/main_esm.html new file mode 100644 index 00000000..192c0d94 --- /dev/null +++ b/cmcd/test/pages/main_esm.html @@ -0,0 +1,29 @@ + + + + + + + + +
+
+
+ + + + diff --git a/cmcd/test/pages/main_umd.html b/cmcd/test/pages/main_umd.html new file mode 100644 index 00000000..4482cafe --- /dev/null +++ b/cmcd/test/pages/main_umd.html @@ -0,0 +1,29 @@ + + + + + + + + + +
+
+
+ + + + diff --git a/cmcd/test/unit/CMCDPayloadUtils.spec.ts b/cmcd/test/unit/CMCDPayloadUtils.spec.ts new file mode 100644 index 00000000..c89b2ce4 --- /dev/null +++ b/cmcd/test/unit/CMCDPayloadUtils.spec.ts @@ -0,0 +1,147 @@ +import { CMCDObjectType, CMCDStreamingFormat, CMCDStreamType } from '../../src'; +import { transformToQueryParameters } from '../../src/CMCDPayloadUtils'; + +describe('transformToQueryParameters', () => { + it('returns an empty string if there are no parameters', () => { + expect(transformToQueryParameters({})).toBe(''); + }); + + it('serialises booleans which are true without equals sign', () => { + expect(transformToQueryParameters({ bs: true })).toBe('bs'); + }); + + it('serialises booleans which are false with equals `false`', () => { + expect(transformToQueryParameters({ bs: false })).toBe('bs=false'); + }); + + it('serialises multiple key/value pairs with a comma in between', () => { + expect(transformToQueryParameters({ bs: true, su: true })).toBe('bs,su'); + }); + + it('serialises strings with double quotes', () => { + expect(transformToQueryParameters({ sid: 'string' })).toBe('sid="string"'); + }); + + it('serialises strings with escaped double quotes', () => { + expect(transformToQueryParameters({ sid: '"' })).toBe('sid="%5C%22"'); + }); + + it('serialises strings with escaped backslashes', () => { + expect(transformToQueryParameters({ sid: '\\' })).toBe('sid="%5C%5C"'); + }); + + it('serialises object type with its token', () => { + expect(transformToQueryParameters({ ot: CMCDObjectType.AUDIO })).toBe('ot="a"'); + }); + + it('serialises streaming format with its token', () => { + expect(transformToQueryParameters({ sf: CMCDStreamingFormat.HLS })).toBe('sf="h"'); + }); + + it('serialises stream type with its token', () => { + expect(transformToQueryParameters({ st: CMCDStreamType.DYNAMIC })).toBe('st="l"'); + }); + + it('serialises parameters in a concatenated string with all parameters sorted in ascending alphabetic order', () => { + expect(transformToQueryParameters({ tb: 1, br: 2, rtp: 3 })).toBe('br=2,rtp=3,tb=1'); + }); + + it('serialises parameters in a concatenated string as example 1', () => { + expect(transformToQueryParameters({ sid: '6e2fb550-c457-11e9-bb97-0800200c9a66' })).toBe( + 'sid="6e2fb550-c457-11e9-bb97-0800200c9a66"' + ); + }); + + it('serialises parameters in a concatenated string as example 2', () => { + expect( + transformToQueryParameters({ + br: 3200, + bs: true, + d: 4004, + mtp: 25400, + ot: CMCDObjectType.VIDEO, + rtp: 15000, + sid: '6e2fb550-c457-11e9-bb97-0800200c9a66', + tb: 6000 + }) + ).toBe('br=3200,bs,d=4004,mtp=25400,ot="v",rtp=15000,sid="6e2fb550-c457-11e9-bb97-0800200c9a66",tb=6000'); + }); + + it('serialises parameters in a concatenated string as example 3', () => { + expect( + transformToQueryParameters({ + bs: true, + rtp: 15000, + sid: '6e2fb550-c457-11e9-bb97-0800200c9a66' + }) + ).toBe('bs,rtp=15000,sid="6e2fb550-c457-11e9-bb97-0800200c9a66"'); + }); + + it('serialises parameters in a concatenated string as example 4', () => { + expect(transformToQueryParameters({ bs: true, su: true })).toBe('bs,su'); + }); + + it('serialises parameters in a concatenated string as example 5 (but with d being at the end due to sort order)', () => { + expect( + transformToQueryParameters({ + d: 4004, + 'com.example-myNumericKey': 500, + 'com.example-myStringKey': 'myStringValue' + }) + ).toBe('com.example-myNumericKey=500,com.example-myStringKey="myStringValue",d=4004'); + }); + + it('serialises parameters in a concatenated string as example 6', () => { + expect( + transformToQueryParameters({ + nor: '../300kbps/segment35.m4v', + sid: '6e2fb550-c457-11e9-bb97-0800200c9a66' + }) + ).toBe('nor="..%2F300kbps%2Fsegment35.m4v",sid="6e2fb550-c457-11e9-bb97-0800200c9a66"'); + }); + + it('serialises parameters in a concatenated string as example 7', () => { + expect( + transformToQueryParameters({ + nrr: '12323-48763', + sid: '6e2fb550-c457-11e9-bb97-0800200c9a66' + }) + ).toBe('nrr="12323-48763",sid="6e2fb550-c457-11e9-bb97-0800200c9a66"'); + }); + + it('serialises parameters in a concatenated string as example 8', () => { + expect( + transformToQueryParameters({ + nor: '../300kbps/track.m4v', + nrr: '12323-48763', + sid: '6e2fb550-c457-11e9-bb97-0800200c9a66' + }) + ).toBe('nor="..%2F300kbps%2Ftrack.m4v",nrr="12323-48763",sid="6e2fb550-c457-11e9-bb97-0800200c9a66"'); + }); + + it('serialises parameters in a concatenated string as example 9', () => { + expect( + transformToQueryParameters({ + bl: 21300, + br: 3200, + bs: true, + cid: 'faec5fc2-ac30-11ea-bb37-0242ac130002', + d: 4004, + dl: 18500, + mtp: 48100, + nor: '../300kbps/track.m4v', + nrr: '12323-48763', + ot: CMCDObjectType.VIDEO, + pr: 1.08, + rtp: 12000, + sf: CMCDStreamingFormat.MPEG_DASH, + sid: '6e2fb550-c457-11e9-bb97-0800200c9a66', + st: CMCDStreamType.STATIC, + su: true, + tb: 6000 + }) + ).toBe( + 'bl=21300,br=3200,bs,cid="faec5fc2-ac30-11ea-bb37-0242ac130002",d=4004,dl=18500,mtp=48100,nor="..%2F300kbps%2Ftrack.m4v",nrr="12323-48763",ot="v",pr=1.08,rtp=12000,sf="d",sid="6e2fb550-c457-11e9-bb97-0800200c9a66",st="v",su,tb=6000' + ); + }); +}); diff --git a/cmcd/tsconfig.json b/cmcd/tsconfig.json new file mode 100644 index 00000000..5fd53772 --- /dev/null +++ b/cmcd/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "strict": true, + "baseUrl": "./", + "rootDir": ".", + "paths": { "THEOplayer": ["./src/THEOplayer"] }, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/package-lock.json b/package-lock.json index 59808251..8e26ad78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "workspaces": [ "yospace", "conviva", - "nielsen" + "nielsen", + "cmcd" ], "devDependencies": { "@changesets/cli": "^2.27.1", @@ -41,6 +42,14 @@ "typescript": "^5.3.3" } }, + "cmcd": { + "name": "@theoplayer/cmcd-connector-web", + "version": "1.0.0", + "license": "MIT", + "peerDependencies": { + "theoplayer": "^5.0.0 || ^6.0.0" + } + }, "conviva": { "name": "@theoplayer/conviva-connector-web", "version": "2.0.0", @@ -60,7 +69,7 @@ }, "nielsen": { "name": "@theoplayer/nielsen-connector-web", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "peerDependencies": { "theoplayer": "^5.0.0 || ^6.0.0" @@ -2537,6 +2546,10 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@theoplayer/cmcd-connector-web": { + "resolved": "cmcd", + "link": true + }, "node_modules/@theoplayer/conviva-connector-web": { "resolved": "conviva", "link": true diff --git a/package.json b/package.json index 12d23c2f..d432a4e2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "workspaces": [ "yospace", "conviva", - "nielsen" + "nielsen", + "cmcd" ], "scripts": { "changeset:version": "changeset version && node .changeset/post-process.js",