UNPKG

rx-player

Version:
663 lines (628 loc) 23.3 kB
import log from "../../log"; import type { IAdaptation, IManifest, IPeriod, IRepresentation, ISegment, } from "../../manifest"; import type { IReadOnlyPlaybackObserver, IRebufferingStatus, ObservationPosition, } from "../../playback_observer"; import type { ICmcdOptions, ICmcdPayload, ITrackType } from "../../public_types"; import createUuid from "../../utils/create_uuid"; import isNullOrUndefined from "../../utils/is_null_or_undefined"; import type { IRange } from "../../utils/ranges"; import TaskCanceller from "../../utils/task_canceller"; import { getRelativeUrl } from "../../utils/url-utils"; /** * `rtp`, for "REQUESTED_MAXIMUM_THROUGHPUT", indicates the maximum throughput * needed to load a given segment without experience degration. * It acts as a hint to a CDN so it can scale its resources between multiple * clients. * * We could indicate through `rtp` the exact minimum bandwidth needed, but this * may lead to much higher risk of rebuffering, so we prefer to multiply that * value by a safe-enough factor, this `RTP_FACTOR`. */ const RTP_FACTOR = 4; /** * Information that should be provided to the `CmcdDataBuilder` when getting the * CMCD payload for a segment. */ export interface ICmcdSegmentInfo { /** Manifest metadata linked to the wanted segment. */ manifest: IManifest; /** Period metadata linked to the wanted segment. */ period: IPeriod; /** Adaptation metadata linked to the wanted segment. */ adaptation: IAdaptation; /** Representation metadata linked to the wanted segment. */ representation: IRepresentation; /** Segment metadata linked to the wanted segment. */ segment: ISegment; /** * Optional next segment that may be requested after this one. * Should only be set (to something else than `undefined`) if that following * segment is part of the same `Representation`. * * This information is used to produce the "next object request" and "next * range request" part of the CMCD payload, used for segment prefetching. * * If `null` no segment will be requested next for now. * If `undefined` we do not know which next segment will be requested. */ nextSegment: ISegment | null | undefined; } /** * Media playback observation's properties the `CmcdDataBuilder` wants to have * access to. */ export interface ICmcdDataBuilderPlaybackObservation { /** * Ranges of buffered data per type of media. * `null` if no buffer exists for that type of media. */ buffered: Record<ITrackType, IRange[] | null>; /** * Information on the current media position in seconds at the time of the * Observation. */ position: ObservationPosition; /** Target playback rate at which we want to play the content. */ speed: number; /** * Describes when the player is "rebuffering" and what event started that * status. * "Rebuffering" is a status where the player has not enough buffer ahead to * play reliably. * The RxPlayer should pause playback when a playback observation indicates the * rebuffering status. */ rebuffering: IRebufferingStatus | null; } /** * Class allowing to easily obtain "Common Media Client Data" (CMCD) properties * that may be relied on while performing HTTP(S) requests on a CDN. * * @class CmcdDataBuilder */ export default class CmcdDataBuilder { private _sessionId: string; private _contentId: string; private _typePreference: TypePreference; private _lastThroughput: Partial<Record<ITrackType, number | undefined>>; private _playbackObserver: IReadOnlyPlaybackObserver<ICmcdDataBuilderPlaybackObservation> | null; private _bufferStarvationToggle: boolean; private _canceller: TaskCanceller | null; /** * Create a new `CmcdDataBuilder`, linked to the given options (see type * definition). * @param {Object} options */ constructor(options: ICmcdOptions) { this._sessionId = options.sessionId ?? createUuid(); this._contentId = options.contentId ?? createUuid(); this._typePreference = options.communicationType === "headers" ? TypePreference.Headers : TypePreference.QueryString; this._bufferStarvationToggle = false; this._playbackObserver = null; this._lastThroughput = {}; this._canceller = null; } /** * Start listening to the given `playbackObserver` so the `CmcdDataBuilder` * can extract some playback-linked metadata that it needs. * * It will keep listening for media data until `stopMonitoringPlayback` is called. * * If `startMonitoringPlayback` is called again, the previous monitoring is * also cancelled. * @param {Object} playbackObserver */ public startMonitoringPlayback( playbackObserver: IReadOnlyPlaybackObserver<ICmcdDataBuilderPlaybackObservation>, ): void { this._canceller?.cancel(); this._canceller = new TaskCanceller(); this._playbackObserver = playbackObserver; playbackObserver.listen( (obs) => { if (obs.rebuffering !== null) { this._bufferStarvationToggle = true; } }, { includeLastObservation: true, clearSignal: this._canceller.signal }, ); } /** * Stop the monitoring of playback conditions started from the last * `stopMonitoringPlayback` call. */ public stopMonitoringPlayback(): void { this._canceller?.cancel(); this._canceller = null; this._playbackObserver = null; } /** * Update the last measured throughput for a specific media type. * Needed for some of CMCD's properties. * @param {string} trackType * @param {number|undefined} throughput - Last throughput measured for that * media type. `undefined` if unknown. */ public updateThroughput(trackType: ITrackType, throughput: number | undefined) { this._lastThroughput[trackType] = throughput; } /** * Returns the base of data that is common to all resources' requests. * @param {number|undefined} lastThroughput - The last measured throughput to * provide. `undefined` to provide no throughput. * @returns {Object} */ private _getCommonCmcdData(lastThroughput: number | undefined): ICmcdProperties { const props: ICmcdProperties = {}; props.bs = this._bufferStarvationToggle; this._bufferStarvationToggle = false; props.cid = this._contentId; props.mtp = lastThroughput !== undefined ? Math.floor(Math.round(lastThroughput / 1000 / 100) * 100) : undefined; props.sid = this._sessionId; const lastObservation = this._playbackObserver?.getReference().getValue(); props.pr = lastObservation === undefined || lastObservation.speed === 1 ? undefined : lastObservation.speed; if (lastObservation !== undefined) { props.su = lastObservation.rebuffering !== null; } return props; } /** * For the given type of Manifest, returns the corresponding CMCD payload * that should be provided alongside its request. * @param {string} transportType * @returns {Object} */ public getCmcdDataForManifest(transportType: string): ICmcdPayload { const props = this._getCommonCmcdData( this._lastThroughput.video ?? this._lastThroughput.audio, ); props.ot = "m"; switch (transportType) { case "dash": props.sf = "d"; break; case "smooth": props.sf = "s"; break; default: props.sf = "o"; break; } return this._producePayload(props); } /** * For the given segment information, returns the corresponding CMCD payload * that should be provided alongside its request. * @param {Object} content * @returns {Object} */ public getCmcdDataForSegmentRequest(content: ICmcdSegmentInfo): ICmcdPayload { const lastObservation = this._playbackObserver?.getReference().getValue(); const props = this._getCommonCmcdData(this._lastThroughput[content.adaptation.type]); props.br = Math.round(content.representation.bitrate / 1000); props.d = Math.round(content.segment.duration * 1000); switch (content.adaptation.type) { case "video": props.ot = "v"; break; case "audio": props.ot = "a"; break; case "text": props.ot = "c"; break; } if (content.segment.isInit) { props.ot = "i"; } if ( !isNullOrUndefined(content.nextSegment) && content.segment.url !== null && content.nextSegment.url !== null ) { // We add a special case for some initialization segment which need // multiple byte-ranges to fully request, as the `CmcdDataBuilder` // is not supposed to keep track of how the requesting part of the // RxPlayer actually perform its multi-byte-range requests if (!content.nextSegment.isInit || content.nextSegment.indexRange === undefined) { const currSegmentUrl = content.segment.url; const nextSegmentUrl = content.nextSegment.url; const relativeUrl = getRelativeUrl(currSegmentUrl, nextSegmentUrl); if (relativeUrl !== null) { if (relativeUrl !== ".") { props.nor = encodeURIComponent(relativeUrl); } if (content.nextSegment.range !== undefined) { props.nrr = String(content.nextSegment.range[0]) + "-"; if (isFinite(content.nextSegment.range[1])) { props.nrr += String(content.nextSegment.range[1]); } } } } } let precizeBufferLengthMs; if ( lastObservation !== undefined && (props.ot === "v" || props.ot === "a" || props.ot === "av") ) { const bufferedForType = lastObservation.buffered[content.adaptation.type]; if (!isNullOrUndefined(bufferedForType)) { // TODO more precize position estimate? const position = this._playbackObserver?.getCurrentTime() ?? lastObservation.position.getWanted() ?? lastObservation.position.getPolled(); for (const range of bufferedForType) { if (position >= range.start && position < range.end) { precizeBufferLengthMs = (range.end - position) * 1000; props.bl = Math.floor(Math.round(precizeBufferLengthMs / 100) * 100); break; } } } } const precizeDeadlineMs = precizeBufferLengthMs === undefined || lastObservation === undefined ? undefined : precizeBufferLengthMs / lastObservation.speed; props.dl = precizeDeadlineMs === undefined ? undefined : Math.floor(Math.round(precizeDeadlineMs / 100) * 100); if (precizeDeadlineMs !== undefined) { // estimate the file size, in kilobits const estimatedFileSizeKb = (content.representation.bitrate * content.segment.duration) / 1000; const wantedCeilBandwidthKbps = estimatedFileSizeKb / (precizeDeadlineMs / 1000); props.rtp = Math.floor( Math.round((wantedCeilBandwidthKbps * RTP_FACTOR) / 100) * 100, ); } switch (content.manifest.transport) { case "dash": props.sf = "d"; break; case "smooth": props.sf = "s"; break; default: props.sf = "o"; break; } props.st = content.manifest.isDynamic ? "l" : "v"; props.tb = content.adaptation.representations.reduce( (acc: number | undefined, representation: IRepresentation) => { if (representation.isPlayable() !== true) { return acc; } if (acc === undefined) { return Math.round(representation.bitrate / 1000); } return Math.max(acc, Math.round(representation.bitrate / 1000)); }, undefined, ); return this._producePayload(props); } /** * From the given CMCD properties, produce the corresponding payload according * to current settings. * @param {Object} props * @returns {Object} */ private _producePayload(props: ICmcdProperties): ICmcdPayload { const headers = { object: "", request: "", session: "", status: "", }; let queryStringPayload = ""; const addPayload = (payload: string, headerName: keyof typeof headers): void => { if (this._typePreference === TypePreference.Headers) { headers[headerName] += payload; } else { queryStringPayload += payload; } }; const addNumberProperty = ( prop: "br" | "bl" | "d" | "dl" | "mtp" | "pr" | "rtp" | "tb", headerName: keyof typeof headers, ): void => { const val = props[prop]; if (val !== undefined) { const toAdd = `${prop}=${String(val)},`; addPayload(toAdd, headerName); } }; const addBooleanProperty = ( prop: "bs" | "su", headerName: keyof typeof headers, ): void => { if (props[prop] === true) { const toAdd = `${prop},`; addPayload(toAdd, headerName); } }; const addStringProperty = ( prop: "cid" | "sid" | "nor" | "nrr", headerName: keyof typeof headers, ): void => { const val = props[prop]; if (val !== undefined) { const formatted = `"${val.replace("\\", "\\\\").replace('"', '\\"')}"`; const toAdd = `prop=${formatted},`; addPayload(toAdd, headerName); } }; const addTokenProperty = ( prop: "ot" | "sf" | "st", headerName: keyof typeof headers, ): void => { const val = props[prop]; if (val !== undefined) { const toAdd = `prop=${val},`; addPayload(toAdd, headerName); } }; addNumberProperty("bl", "request"); addNumberProperty("br", "object"); addBooleanProperty("bs", "status"); addStringProperty("cid", "session"); addNumberProperty("d", "object"); addNumberProperty("dl", "request"); addNumberProperty("mtp", "request"); addStringProperty("nor", "request"); addStringProperty("nrr", "request"); addTokenProperty("ot", "object"); addNumberProperty("pr", "session"); addNumberProperty("rtp", "status"); addTokenProperty("sf", "session"); addStringProperty("sid", "session"); addTokenProperty("st", "session"); addBooleanProperty("su", "request"); addNumberProperty("tb", "object"); if (this._typePreference === TypePreference.Headers) { if (headers.object[headers.object.length - 1] === ",") { headers.object = headers.object.substring(0, headers.object.length - 1); } if (headers.request[headers.request.length - 1] === ",") { headers.request = headers.request.substring(0, headers.request.length - 1); } if (headers.session[headers.session.length - 1] === ",") { headers.session = headers.session.substring(0, headers.session.length - 1); } if (headers.status[headers.status.length - 1] === ",") { headers.status = headers.status.substring(0, headers.status.length - 1); } log.debug("CMCD", "proposing headers payload"); return { type: "headers", value: { /* eslint-disable @typescript-eslint/naming-convention */ "CMCD-Object": headers.object, "CMCD-Request": headers.request, "CMCD-Session": headers.session, "CMCD-Status": headers.status, /* eslint-enable @typescript-eslint/naming-convention */ }, }; } if (queryStringPayload[queryStringPayload.length - 1] === ",") { queryStringPayload = queryStringPayload.substring(0, queryStringPayload.length - 1); } queryStringPayload = encodeURIComponent(queryStringPayload); log.debug("CMCD", "proposing query string payload", { queryString: queryStringPayload, }); return { type: "query", value: [["CMCD", queryStringPayload]], }; } } /** * How CMCD metadata should be communicated. */ const enum TypePreference { /** The CMCD metadata should be communicated through HTTP request headers. */ Headers, /** The CMCD metadata should be communicated through a query string. */ QueryString, } /** Every properties that can be constructed under the CMCD scheme. */ interface ICmcdProperties { /* * Encoded bitrate (br) * 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. * * In kbps. */ br?: number | undefined; /** * Buffer starvation (bs) * 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. */ bs?: boolean | undefined; /** * Buffer length (bl) * 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’. * In milliseconds */ bl?: number | undefined; /** * Content ID (cid) * 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 */ cid?: string | undefined; /** * Object duration (d) * 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. * In milliseconds. */ d?: number | undefined; /** * Deadline (dl) * 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. * In milliseconds. */ dl?: number | undefined; /** * Measured throughput (mtp) * 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. * In kbps. */ mtp?: number | undefined; /** * Next Object Request (nor) * 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. */ nor?: string | undefined; /** * Next Range Request (nrr) * 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: * "<range-start>-" * "<range-start>-<range-end>" * "-<suffix-length>" */ nrr?: string | undefined; /** * Object type (ot) * 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?: string | undefined; /** * Playback rate (pr) * 1 if real-time, 2 if double speed, 0 if not playing. * SHOULD only be sent if not equal to 1. */ pr?: number | undefined; /** * Requested maximum throughput (rtp) * 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. * In kbps. */ rtp?: number | undefined; /** * Streaming format (sf) * 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?: string | undefined; /** * Session ID (sid) * 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 */ sid?: string | undefined; /** * Stream type (st) * v = all segments are available – e.g., VOD * l = segments become available over time – e.g., LIVE */ st?: string | undefined; /** * Startup (su) * 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. */ su?: boolean | undefined; /** * Top bitrate (tb) * The highest bitrate rendition in the manifest or playlist that the client * is allowed to play, given current codec, licensing and sizing constraints. * In kbps. */ tb?: number | undefined; }