From da1ad63993ff2f9b6f6ab5ebbdb23e9d81358557 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Mon, 24 Jun 2024 18:40:36 +0200 Subject: [PATCH] CMCD: Add nrr and nor handling Previous iteration of CMCD [#1461] did not handle "nrr" nor... "nor" attributes. The former (nrr) gives a hint to the CDN of what the next byte range request going to be (if it's one) and the later (nor) indicates the relative URL based on the current one. Note that if it's the same segment, the relative URL is just the file name, as it seems logical for me - and as I didn't find an explicit mention of this frequent case in the CMCD specification. Even if the documentation for `nrr` indicates that an absent `nor` but a present `nrr` is assumed to mean the same segment. --- src/core/cmcd/cmcd_data_builder.ts | 65 ++++++++++++++++++- .../segment/prioritized_segment_fetcher.ts | 13 +++- src/core/fetchers/segment/segment_fetcher.ts | 24 ++++++- src/core/fetchers/segment/segment_queue.ts | 8 ++- .../load_and_push_segment.ts | 1 + src/utils/__tests__/resolve_url.test.ts | 10 +-- 6 files changed, 108 insertions(+), 13 deletions(-) diff --git a/src/core/cmcd/cmcd_data_builder.ts b/src/core/cmcd/cmcd_data_builder.ts index 53aa68961d..dbfebfb0be 100644 --- a/src/core/cmcd/cmcd_data_builder.ts +++ b/src/core/cmcd/cmcd_data_builder.ts @@ -15,6 +15,7 @@ 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 { getRelativePathUrl } from "../../utils/resolve_url"; import TaskCanceller from "../../utils/task_canceller"; /** @@ -171,12 +172,29 @@ export default class CmcdDataBuilder { return this._producePayload(props); } - public getCmcdDataForSegmentRequest(content: ICmcdSegmentInfo): ICmcdPayload { + public getCmcdDataForSegmentRequest( + content: ICmcdSegmentInfo, + nextSegment: ISegment | undefined | null, + ): 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); + + if ( + !isNullOrUndefined(nextSegment) && + content.segment.url !== null && + nextSegment.url !== null + ) { + const relativePath = getRelativePathUrl(content.segment.url, nextSegment.url); + if (relativePath !== null) { + props.nor = relativePath; + if (nextSegment.range !== undefined && nextSegment.indexRange === undefined) { + props.nrr = `${nextSegment.range[0]}-${nextSegment.range[1] === Infinity ? "" : nextSegment.range[1]}`; + } + } + } // TODO nor (next object request) and nrr (next range request) switch (content.adaptation.type) { @@ -329,6 +347,22 @@ export default class CmcdDataBuilder { queryStringPayload += toAdd; } } + if (props.nor !== undefined) { + const toAdd = `nor=${formatStringPayload(props.nor)},`; + if (this._typePreference === TypePreference.Headers) { + cmcdRequestValue += toAdd; + } else { + queryStringPayload += toAdd; + } + } + if (props.nrr !== undefined) { + const toAdd = `nrr=${formatStringPayload(props.nrr)},`; + if (this._typePreference === TypePreference.Headers) { + cmcdRequestValue += toAdd; + } else { + queryStringPayload += toAdd; + } + } if (props.ot !== undefined) { const toAdd = `ot=${props.ot},`; if (this._typePreference === TypePreference.Headers) { @@ -595,6 +629,35 @@ interface ICmcdProperties { * In kbps. */ tb?: 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: + * "-" + * "-" + * "-" + */ + nrr?: string | undefined; } function formatStringPayload(str: string): string { diff --git a/src/core/fetchers/segment/prioritized_segment_fetcher.ts b/src/core/fetchers/segment/prioritized_segment_fetcher.ts index a5dfba9601..829002c655 100644 --- a/src/core/fetchers/segment/prioritized_segment_fetcher.ts +++ b/src/core/fetchers/segment/prioritized_segment_fetcher.ts @@ -15,6 +15,7 @@ */ import log from "../../../log"; +import type { ISegment } from "../../../manifest"; import type { CancellationSignal } from "../../../utils/task_canceller"; import type { ISegmentFetcher, @@ -51,6 +52,14 @@ export default function applyPrioritizerToSegmentFetcher( * @param {Object} content - content to request * @param {Number} priority - priority at which the content should be requested. * Lower number == higher priority. + * @param {Object|null|undefined} nextSegment - Information on the next + * segment that will be loaded after this one. + * + * Can be relied on in very specific scenarios to optimize caching on the + * CDN-side. + * + * Can be set to `undefined` if you do not care about this optimization, or to + * `null` if there's no segment after this one. * @param {Object} callbacks * @param {Object} cancelSignal * @returns {Promise} @@ -58,11 +67,12 @@ export default function applyPrioritizerToSegmentFetcher( createRequest( content: ISegmentLoaderContent, priority: number, + nextSegment: ISegment | undefined | null, callbacks: IPrioritizedSegmentFetcherCallbacks, cancelSignal: CancellationSignal, ): Promise { const givenTask = (innerCancelSignal: CancellationSignal) => { - return fetcher(content, callbacks, innerCancelSignal); + return fetcher(content, nextSegment, callbacks, innerCancelSignal); }; const ret = prioritizer.create(givenTask, priority, callbacks, cancelSignal); taskHandlers.set(ret, givenTask); @@ -92,6 +102,7 @@ export interface IPrioritizedSegmentFetcher { createRequest: ( content: ISegmentLoaderContent, priority: number, + nextSegment: ISegment | undefined | null, callbacks: IPrioritizedSegmentFetcherCallbacks, cancellationSignal: CancellationSignal, ) => Promise; diff --git a/src/core/fetchers/segment/segment_fetcher.ts b/src/core/fetchers/segment/segment_fetcher.ts index 1aa1068464..0d0bf6081e 100644 --- a/src/core/fetchers/segment/segment_fetcher.ts +++ b/src/core/fetchers/segment/segment_fetcher.ts @@ -120,12 +120,21 @@ export default function createSegmentFetcher( /** * Fetch a specific segment. * @param {Object} content + * @param {Object|null|undefined} nextSegment - Information on the next + * segment that will be loaded after this one. + * + * Can be relied on in very specific scenarios to optimize caching on the + * CDN-side. + * + * Can be set to `undefined` if you do not care about this optimization, or to + * `null` if there's no segment after this one. * @param {Object} fetcherCallbacks * @param {Object} cancellationSignal * @returns {Promise} */ return async function fetchSegment( content: ISegmentLoaderContent, + nextSegment: ISegment | undefined | null, fetcherCallbacks: ISegmentFetcherCallbacks, cancellationSignal: CancellationSignal, ): Promise { @@ -293,7 +302,10 @@ export default function createSegmentFetcher( function callLoaderWithUrl( cdnMetadata: ICdnMetadata | null, ): ReturnType> { - requestOptions.cmcdPayload = cmcdDataBuilder?.getCmcdDataForSegmentRequest(content); + requestOptions.cmcdPayload = cmcdDataBuilder?.getCmcdDataForSegmentRequest( + content, + nextSegment, + ); return loadSegment( cdnMetadata, context, @@ -398,6 +410,16 @@ export default function createSegmentFetcher( export type ISegmentFetcher = ( /** Information on the segment wanted. */ content: ISegmentLoaderContent, + /** + * Information on the next segment that will be loaded after this one. + * + * Can be relied on in very specific scenarios to optimize caching on the + * CDN-side. + * + * Can be set to `undefined` if you do not care about this optimization, or to + * `null` if there's no segment after this one. + */ + nextSegment: ISegment | undefined | null, /** Callbacks the `ISegmentFetcher` will call as it loads the data. */ callbacks: ISegmentFetcherCallbacks, /** CancellationSignal allowing to cancel the request. */ diff --git a/src/core/fetchers/segment/segment_queue.ts b/src/core/fetchers/segment/segment_queue.ts index a833fd3276..bc735b3316 100644 --- a/src/core/fetchers/segment/segment_queue.ts +++ b/src/core/fetchers/segment/segment_queue.ts @@ -257,8 +257,10 @@ export default class SegmentQueue extends EventEmitter> const { downloadQueue, content, initSegmentInfoRef, currentCanceller } = contentInfo; const { segmentQueue } = downloadQueue.getValue(); const currentNeededSegment = segmentQueue[0]; + const currentNextSegment = segmentQueue[1]?.segment ?? null; const recursivelyRequestSegments = ( startingSegment: IQueuedSegment | undefined, + nextSegment: ISegment | undefined | null, ): void => { if (currentCanceller !== null && currentCanceller.isUsed()) { contentInfo.mediaSegmentRequest = null; @@ -318,13 +320,14 @@ export default class SegmentQueue extends EventEmitter> lastQueue.shift(); } isComplete = true; - recursivelyRequestSegments(lastQueue[0]); + recursivelyRequestSegments(lastQueue[0], lastQueue[1]?.segment ?? null); }; /** Scheduled actual segment request. */ const request = this._segmentFetcher.createRequest( context, priority, + nextSegment, { /** * Callback called when the request has to be retried. @@ -422,7 +425,7 @@ export default class SegmentQueue extends EventEmitter> contentInfo.mediaSegmentRequest = { segment, priority, request, canceller }; }; - recursivelyRequestSegments(currentNeededSegment); + recursivelyRequestSegments(currentNeededSegment, currentNextSegment); } /** @@ -460,6 +463,7 @@ export default class SegmentQueue extends EventEmitter> const request = this._segmentFetcher.createRequest( context, priority, + undefined, { onRetry: (err: IPlayerError): void => { this.trigger("requestRetry", { segment, error: err }); diff --git a/src/experimental/tools/VideoThumbnailLoader/load_and_push_segment.ts b/src/experimental/tools/VideoThumbnailLoader/load_and_push_segment.ts index d60435df00..6c3cbfab66 100644 --- a/src/experimental/tools/VideoThumbnailLoader/load_and_push_segment.ts +++ b/src/experimental/tools/VideoThumbnailLoader/load_and_push_segment.ts @@ -38,6 +38,7 @@ export default function loadAndPushSegment( const pushOperations: Array> = []; return segmentFetcher( segmentInfo, + undefined, { onChunk(parseChunk) { const parsed = parseChunk(undefined); diff --git a/src/utils/__tests__/resolve_url.test.ts b/src/utils/__tests__/resolve_url.test.ts index fce732f151..fde22c9706 100644 --- a/src/utils/__tests__/resolve_url.test.ts +++ b/src/utils/__tests__/resolve_url.test.ts @@ -366,10 +366,7 @@ describe("utils - getRelativePathUrl", () => { it("should return `null` for different domains", () => { expect( - getRelativePathUrl( - "https://www.example.fr/a.mp4", - "https://www.example.com/b.mp4", - ), + getRelativePathUrl("https://www.example.fr/a.mp4", "https://www.example.com/b.mp4"), ).toEqual(null); expect( getRelativePathUrl( @@ -387,10 +384,7 @@ describe("utils - getRelativePathUrl", () => { it("should return `null` for different schemes", () => { expect( - getRelativePathUrl( - "http://www.example.fr/a.mp4", - "https://www.example.com/b.mp4", - ), + getRelativePathUrl("http://www.example.fr/a.mp4", "https://www.example.com/b.mp4"), ).toEqual(null); expect( getRelativePathUrl(