From 420d99e054bc09ca940ae422133a20fbc34ed08b Mon Sep 17 00:00:00 2001 From: Florent Date: Mon, 26 Feb 2024 13:56:03 +0100 Subject: [PATCH] Implement ContentSteering --- src/Content_Steering.md | 87 ++++++ src/core/fetchers/cdn_prioritizer.ts | 259 ++++++++++++++++-- .../segment/segment_fetcher_creator.ts | 4 +- src/core/fetchers/steering_manifest/index.ts | 23 ++ .../steering_manifest_fetcher.ts | 176 ++++++++++++ src/core/fetchers/utils/schedule_request.ts | 65 ++++- src/core/main/worker/content_preparer.ts | 20 +- src/core/main/worker/worker_main.ts | 6 +- src/default_config.ts | 15 + .../video_thumbnail_loader.ts | 1 + .../init/media_source_content_initializer.ts | 1 + src/manifest/classes/manifest.ts | 6 +- .../SteeringManifest/DCSM/parse_dcsm.ts | 67 +++++ src/parsers/SteeringManifest/index.ts | 17 ++ src/parsers/SteeringManifest/types.ts | 21 ++ src/parsers/manifest/dash/common/parse_mpd.ts | 14 +- .../manifest/dash/common/resolve_base_urls.ts | 2 +- .../fast-js-parser/node_parsers/BaseURL.ts | 16 +- .../node_parsers/ContentSteering.ts | 51 ++++ .../dash/fast-js-parser/node_parsers/MPD.ts | 15 +- .../__tests__/AdaptationSet.test.ts | 17 +- .../native-parser/node_parsers/BaseURL.ts | 13 +- .../node_parsers/ContentSteering.ts | 63 +++++ .../dash/native-parser/node_parsers/MPD.ts | 15 +- .../__tests__/AdaptationSet.test.ts | 38 ++- .../manifest/dash/node_parser_types.ts | 42 +++ .../manifest/dash/wasm-parser/rs/events.rs | 9 + .../wasm-parser/rs/processor/attributes.rs | 14 + .../dash/wasm-parser/rs/processor/mod.rs | 46 ++++ .../dash/wasm-parser/ts/generators/BaseURL.ts | 16 +- .../ts/generators/ContentSteering.ts | 71 +++++ .../dash/wasm-parser/ts/generators/MPD.ts | 14 + .../manifest/dash/wasm-parser/ts/types.ts | 3 + .../manifest/local/parse_local_manifest.ts | 1 + .../metaplaylist/metaplaylist_parser.ts | 1 + src/parsers/manifest/smooth/create_parser.ts | 1 + src/parsers/manifest/types.ts | 12 +- src/transports/dash/pipelines.ts | 5 + .../dash/steering_manifest_pipeline.ts | 59 ++++ src/transports/local/pipelines.ts | 1 + src/transports/metaplaylist/pipelines.ts | 1 + src/transports/smooth/pipelines.ts | 1 + src/transports/types.ts | 87 ++++++ 43 files changed, 1329 insertions(+), 67 deletions(-) create mode 100644 src/Content_Steering.md create mode 100644 src/core/fetchers/steering_manifest/index.ts create mode 100644 src/core/fetchers/steering_manifest/steering_manifest_fetcher.ts create mode 100644 src/parsers/SteeringManifest/DCSM/parse_dcsm.ts create mode 100644 src/parsers/SteeringManifest/index.ts create mode 100644 src/parsers/SteeringManifest/types.ts create mode 100644 src/parsers/manifest/dash/fast-js-parser/node_parsers/ContentSteering.ts create mode 100644 src/parsers/manifest/dash/native-parser/node_parsers/ContentSteering.ts create mode 100644 src/parsers/manifest/dash/wasm-parser/ts/generators/ContentSteering.ts create mode 100644 src/transports/dash/steering_manifest_pipeline.ts diff --git a/src/Content_Steering.md b/src/Content_Steering.md new file mode 100644 index 00000000000..e9c71e4ffa8 --- /dev/null +++ b/src/Content_Steering.md @@ -0,0 +1,87 @@ +# Content Steering implementation + +**LAST UPDATE: 2022-08-04** + +## Overview + +Content steering is a mechanism allowing a content provider to deterministically +prioritize a, or multiple, CDN over others - even during content playback - on the +server-side when multiple CDNs are available to load a given content. + +For example, a distributor may want to rebalance load between multiple servers while final +users are watching the corresponding stream, though many other use cases and reasons +exist. + +As of now, content steering only exist for HLS and DASH OTT streaming technologies. In +both cases it takes the form of a separate file, in DASH called the "DASH Content Steering +Manifest" (or DCSM), giving the current priority. This separate file has its own syntax, +semantic and refreshing logic. + +## Architecture in the RxPlayer + +``` + /parsers/SteeringManifest + +----------------------------------+ + | Content Steering Manifest parser | Parse DCSM[1] into a + +----------------------------------+ transport-agnostic steering + ^ Manifest structure + | + | Uses when parsing + | + | + | /transports + +---------------------------+ + | Transport | + | | + | new functions: | + | - loadSteeringManifest | Construct DCSM[1]'s URL, performs + | - parseSteeringManifest | requests and parses it. + +---------------------------+ + ^ + | + | Relies on + | + | + | /core/fetchers/steering_manifest + +-------------------------+ + | SteeringManifestFetcher | Fetches and parses a Content Steering + +-------------------------+ Manifest in a transport-agnostic way + ^ + handle retries and error formatting + | + | Uses an instance of to load, parse and refresh the + | Steering Manifest periodically according to its TTL[2] + | + | + | /core/fetchers/cdn_prioritizer.ts + +----------------+ Signals the priority between multiple + | CdnPrioritizer | potential CDNs for each resource. + +----------------+ (This is done on demand, the `CdnPrioritizer` + ^ knows of no resource in advance). + | + | Asks to sort a segment's available base urls by order of + | priority (and to filter out those that should not be + | used). + | Also signals when it should prevent a base url from + | being used temporarily (e.g. due to request issues). + | + | + | /core/fetchers/segment + +----------------+ + | SegmentFetcher | Fetches and parses a segment in a + +----------------+ transport-agnostic way + ^ + handle retries and error formatting + | + | Ask to load segment(s) + | + | /core/stream/representation + +----------------+ + | Representation | Logic behind finding the right segment to + | Stream | load, loading it and pushing it to the buffer. + +----------------+ One RepresentationStream is created per + actively-loaded Period and one per + actively-loaded buffer type. + + +[1] DCSM: DASH Content Steering Manifest +[2] TTL: Time To Live: a delay after which a Content Steering Manifest should be refreshed +``` diff --git a/src/core/fetchers/cdn_prioritizer.ts b/src/core/fetchers/cdn_prioritizer.ts index 195d113473b..246e1cce6b4 100644 --- a/src/core/fetchers/cdn_prioritizer.ts +++ b/src/core/fetchers/cdn_prioritizer.ts @@ -15,27 +15,53 @@ */ import config from "../../config"; -import type { ICdnMetadata } from "../../parsers/manifest"; +import { formatError } from "../../errors"; +import log from "../../log"; +import type { IManifest } from "../../manifest"; +import type { ICdnMetadata, IContentSteeringMetadata } from "../../parsers/manifest"; +import type { ISteeringManifest } from "../../parsers/SteeringManifest"; +import type { IPlayerError } from "../../public_types"; +import type { ITransportPipelines } from "../../transports"; import arrayFindIndex from "../../utils/array_find_index"; +import arrayIncludes from "../../utils/array_includes"; import EventEmitter from "../../utils/event_emitter"; +import globalScope from "../../utils/global_scope"; +import SharedReference from "../../utils/reference"; +import SyncOrAsync from "../../utils/sync_or_async"; +import type { ISyncOrAsyncValue } from "../../utils/sync_or_async"; import type { CancellationSignal } from "../../utils/task_canceller"; +import TaskCanceller, { CancellationError } from "../../utils/task_canceller"; +import SteeringManifestFetcher from "./steering_manifest"; /** * Class storing and signaling the priority between multiple CDN available for * any given resource. * - * This class was first created to implement the complexities behind - * Content Steering features, though its handling hasn't been added yet as we - * wait for its specification to be both standardized and relied on in the wild. - * In the meantime, it acts as an abstraction for the simple concept of - * avoiding to request a CDN for any segment when an issue is encountered with - * one (e.g. HTTP 500 statuses) and several CDN exist for a given resource. It - * should be noted that this is also one of the planified features of the - * Content Steering specification. + * It might rely behind the hood on a fetched document giving priorities such as + * a Content Steering Manifest and also on issues that appeared with some given + * CDN in the [close] past. + * + * This class might perform requests and schedule timeouts by itself to keep its + * internal list of CDN priority up-to-date. + * When it is not needed anymore, you should call the `dispose` method to clear + * all resources. + * + * This class was created to implement the complexities behind Content Steering + * features. * * @class CdnPrioritizer */ export default class CdnPrioritizer extends EventEmitter { + /** + * Metadata parsed from the last Content Steering Manifest loaded. + * + * `null` either if there's no such Manifest or if it is currently being + * loaded for the first time. + */ + private _lastSteeringManifest: ISteeringManifest | null; + + private _defaultCdnId: string | undefined; + /** * Structure keeping a list of CDN currently downgraded. * Downgraded CDN immediately have a lower priority than any non-downgraded @@ -60,12 +86,103 @@ export default class CdnPrioritizer extends EventEmitter }; /** + * TaskCanceller allowing to abort the process of loading and refreshing the + * Content Steering Manifest. + * Set to `null` when no such process is pending. + */ + private _steeringManifestUpdateCanceller: TaskCanceller | null; + + private _readyState: SharedReference; + + /** + * @param {Object} manifest + * @param {Object} transport * @param {Object} destroySignal */ - constructor(destroySignal: CancellationSignal) { + constructor( + manifest: IManifest, + transport: ITransportPipelines, + destroySignal: CancellationSignal, + ) { super(); + this._lastSteeringManifest = null; this._downgradedCdnList = { metadata: [], timeouts: [] }; + this._steeringManifestUpdateCanceller = null; + this._defaultCdnId = manifest.contentSteering?.defaultId; + + const steeringManifestFetcher = + transport.steeringManifest === null + ? null + : new SteeringManifestFetcher(transport.steeringManifest, { + maxRetry: undefined, + }); + + let currentContentSteering = manifest.contentSteering; + + manifest.addEventListener( + "manifestUpdate", + () => { + const prevContentSteering = currentContentSteering; + currentContentSteering = manifest.contentSteering; + if (prevContentSteering === null) { + if (currentContentSteering !== null) { + if (steeringManifestFetcher === null) { + log.warn("CP: Steering manifest declared but no way to fetch it"); + } else { + log.info("CP: A Steering Manifest is declared in a new Manifest"); + this._autoRefreshSteeringManifest( + steeringManifestFetcher, + currentContentSteering, + ); + } + } + } else if (currentContentSteering === null) { + log.info("CP: A Steering Manifest is removed in a new Manifest"); + this._steeringManifestUpdateCanceller?.cancel(); + this._steeringManifestUpdateCanceller = null; + } else if ( + prevContentSteering.url !== currentContentSteering.url || + prevContentSteering.proxyUrl !== currentContentSteering.proxyUrl + ) { + log.info("CP: A Steering Manifest's information changed in a new Manifest"); + this._steeringManifestUpdateCanceller?.cancel(); + this._steeringManifestUpdateCanceller = null; + if (steeringManifestFetcher === null) { + log.warn("CP: Steering manifest changed but no way to fetch it"); + } else { + this._autoRefreshSteeringManifest( + steeringManifestFetcher, + currentContentSteering, + ); + } + } + }, + destroySignal, + ); + + if (manifest.contentSteering !== null) { + if (steeringManifestFetcher === null) { + log.warn("CP: Steering Manifest initially present but no way to fetch it."); + this._readyState = new SharedReference("ready"); + } else { + const readyState = manifest.contentSteering.queryBeforeStart + ? "not-ready" + : "ready"; + this._readyState = new SharedReference(readyState); + this._autoRefreshSteeringManifest( + steeringManifestFetcher, + manifest.contentSteering, + ); + } + } else { + this._readyState = new SharedReference("ready"); + } destroySignal.register(() => { + this._readyState.setValue("disposed"); + this._readyState.finish(); + this._steeringManifestUpdateCanceller?.cancel(); + this._steeringManifestUpdateCanceller = null; + this._lastSteeringManifest = null; for (const timeout of this._downgradedCdnList.timeouts) { clearTimeout(timeout); } @@ -87,20 +204,38 @@ export default class CdnPrioritizer extends EventEmitter * @param {Array.} everyCdnForResource - Array of ALL available CDN * able to reach the wanted resource - even those which might not be used in * the end. - * @returns {Array.} - Array of CDN that can be tried to reach the + * @returns {Object} - Array of CDN that can be tried to reach the * resource, sorted by order of CDN preference, according to the * `CdnPrioritizer`'s own list of priorities. + * + * This value is wrapped in a `ISyncOrAsyncValue` as in relatively rare + * scenarios, the order can only be known once the steering Manifest has been + * fetched. */ public getCdnPreferenceForResource( everyCdnForResource: ICdnMetadata[], - ): ICdnMetadata[] { + ): ISyncOrAsyncValue { if (everyCdnForResource.length <= 1) { // The huge majority of contents have only one CDN available. // Here, prioritizing make no sense. - return everyCdnForResource; + return SyncOrAsync.createSync(everyCdnForResource); } - return this._innerGetCdnPreferenceForResource(everyCdnForResource); + if (this._readyState.getValue() === "not-ready") { + const val = new Promise((res, rej) => { + this._readyState.onUpdate((readyState) => { + if (readyState === "ready") { + res(this._innerGetCdnPreferenceForResource(everyCdnForResource)); + } else if (readyState === "disposed") { + rej(new CancellationError()); + } + }); + }); + return SyncOrAsync.createAsync(val); + } + return SyncOrAsync.createSync( + this._innerGetCdnPreferenceForResource(everyCdnForResource), + ); } /** @@ -119,7 +254,8 @@ export default class CdnPrioritizer extends EventEmitter } const { DEFAULT_CDN_DOWNGRADE_TIME } = config.getCurrent(); - const downgradeTime = DEFAULT_CDN_DOWNGRADE_TIME; + const downgradeTime = + this._lastSteeringManifest?.lifetime ?? DEFAULT_CDN_DOWNGRADE_TIME; this._downgradedCdnList.metadata.push(metadata); const timeout = setTimeout(() => { const newIndex = indexOfMetadata(this._downgradedCdnList.metadata, metadata); @@ -153,7 +289,37 @@ export default class CdnPrioritizer extends EventEmitter private _innerGetCdnPreferenceForResource( everyCdnForResource: ICdnMetadata[], ): ICdnMetadata[] { - const [allowedInOrder, downgradedInOrder] = everyCdnForResource.reduce( + let cdnBase; + if (this._lastSteeringManifest !== null) { + const priorities = this._lastSteeringManifest.priorities; + const inSteeringManifest = everyCdnForResource.filter( + (available) => + available.id !== undefined && arrayIncludes(priorities, available.id), + ); + if (inSteeringManifest.length > 0) { + cdnBase = inSteeringManifest; + } + } + + // (If using the SteeringManifest gave nothing, or if it just didn't exist.) */ + if (cdnBase === undefined) { + // (If a default CDN was indicated, try to use it) */ + if (this._defaultCdnId !== undefined) { + const indexOf = arrayFindIndex( + everyCdnForResource, + (x) => x.id !== undefined && x.id === this._defaultCdnId, + ); + if (indexOf >= 0) { + const elem = everyCdnForResource.splice(indexOf, 1)[0]; + everyCdnForResource.unshift(elem); + } + } + + if (cdnBase === undefined) { + cdnBase = everyCdnForResource; + } + } + const [allowedInOrder, downgradedInOrder] = cdnBase.reduce( (acc: [ICdnMetadata[], ICdnMetadata[]], elt: ICdnMetadata) => { if ( this._downgradedCdnList.metadata.some( @@ -171,6 +337,63 @@ export default class CdnPrioritizer extends EventEmitter return allowedInOrder.concat(downgradedInOrder); } + private _autoRefreshSteeringManifest( + steeringManifestFetcher: SteeringManifestFetcher, + contentSteering: IContentSteeringMetadata, + ) { + if (this._steeringManifestUpdateCanceller === null) { + const steeringManifestUpdateCanceller = new TaskCanceller(); + this._steeringManifestUpdateCanceller = steeringManifestUpdateCanceller; + } + const canceller: TaskCanceller = this._steeringManifestUpdateCanceller; + steeringManifestFetcher + .fetch( + contentSteering.url, + (err: IPlayerError) => this.trigger("warnings", [err]), + canceller.signal, + ) + .then((parse) => { + const parsed = parse((errs) => this.trigger("warnings", errs)); + const prevSteeringManifest = this._lastSteeringManifest; + this._lastSteeringManifest = parsed; + if (parsed.lifetime > 0) { + const timeout = globalScope.setTimeout(() => { + canceller.signal.deregister(onTimeoutEnd); + this._autoRefreshSteeringManifest(steeringManifestFetcher, contentSteering); + }, parsed.lifetime * 1000); + const onTimeoutEnd = () => { + clearTimeout(timeout); + }; + canceller.signal.register(onTimeoutEnd); + } + if (this._readyState.getValue() === "not-ready") { + this._readyState.setValue("ready"); + } + if (canceller.isUsed()) { + return; + } + if ( + prevSteeringManifest === null || + prevSteeringManifest.priorities.length !== parsed.priorities.length || + prevSteeringManifest.priorities.some( + (val, idx) => val !== parsed.priorities[idx], + ) + ) { + this.trigger("priorityChange", null); + } + }) + .catch((err) => { + if (err instanceof CancellationError) { + return; + } + const formattedError = formatError(err, { + defaultCode: "NONE", + defaultReason: "Unknown error when fetching and parsing the steering Manifest", + }); + this.trigger("warnings", [formattedError]); + }); + } + /** * @param {number} index */ @@ -181,6 +404,8 @@ export default class CdnPrioritizer extends EventEmitter } } +type ICdnPrioritizerReadyState = "not-ready" | "ready" | "disposed"; + /** Events sent by a `CdnPrioritizer` */ export interface ICdnPrioritizerEvents { /** @@ -190,6 +415,8 @@ export interface ICdnPrioritizerEvents { * is triggered. */ priorityChange: null; + + warnings: IPlayerError[]; } /** diff --git a/src/core/fetchers/segment/segment_fetcher_creator.ts b/src/core/fetchers/segment/segment_fetcher_creator.ts index 21f166ef61e..c6829259707 100644 --- a/src/core/fetchers/segment/segment_fetcher_creator.ts +++ b/src/core/fetchers/segment/segment_fetcher_creator.ts @@ -15,6 +15,7 @@ */ import config from "../../../config"; +import type { IManifest } from "../../../manifest"; import type { ISegmentPipeline, ITransportPipelines } from "../../../transports"; import type { CancellationSignal } from "../../../utils/task_canceller"; import type { IBufferType } from "../../segment_sinks"; @@ -59,10 +60,11 @@ export default class SegmentFetcherCreator { */ constructor( transport: ITransportPipelines, + manifest: IManifest, options: ISegmentFetcherCreatorBackoffOptions, cancelSignal: CancellationSignal, ) { - const cdnPrioritizer = new CdnPrioritizer(cancelSignal); + const cdnPrioritizer = new CdnPrioritizer(manifest, transport, cancelSignal); const { MIN_CANCELABLE_PRIORITY, MAX_HIGH_PRIORITY_LEVEL } = config.getCurrent(); this._transport = transport; diff --git a/src/core/fetchers/steering_manifest/index.ts b/src/core/fetchers/steering_manifest/index.ts new file mode 100644 index 00000000000..6f563e56937 --- /dev/null +++ b/src/core/fetchers/steering_manifest/index.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2015 CANAL+ Group + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SteeringManifestFetcher, { + ISteeringManifestFetcherSettings, + ISteeringManifestParser, +} from "./steering_manifest_fetcher"; + +export default SteeringManifestFetcher; +export { ISteeringManifestFetcherSettings, ISteeringManifestParser }; diff --git a/src/core/fetchers/steering_manifest/steering_manifest_fetcher.ts b/src/core/fetchers/steering_manifest/steering_manifest_fetcher.ts new file mode 100644 index 00000000000..4eb47d95b8c --- /dev/null +++ b/src/core/fetchers/steering_manifest/steering_manifest_fetcher.ts @@ -0,0 +1,176 @@ +/** + * Copyright 2015 CANAL+ Group + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import config from "../../../config"; +import { formatError } from "../../../errors"; +import type { ISteeringManifest } from "../../../parsers/SteeringManifest"; +import type { IPlayerError } from "../../../public_types"; +import type { + IRequestedData, + ITransportSteeringManifestPipeline, +} from "../../../transports"; +import type { CancellationSignal } from "../../../utils/task_canceller"; +import errorSelector from "../utils/error_selector"; +import type { IBackoffSettings } from "../utils/schedule_request"; +import { scheduleRequestPromise } from "../utils/schedule_request"; + +/** Response emitted by a SteeringManifestFetcher fetcher. */ +export type ISteeringManifestParser = + /** Allows to parse a fetched Steering Manifest into a `ISteeringManifest` structure. */ + (onWarnings: (warnings: IPlayerError[]) => void) => ISteeringManifest; + +/** Options used by the `SteeringManifestFetcher`. */ +export interface ISteeringManifestFetcherSettings { + /** Maximum number of time a request on error will be retried. */ + maxRetry: number | undefined; +} + +/** + * Class allowing to facilitate the task of loading and parsing a Content + * Steering Manifest, which is an optional document associated to a content, + * communicating the priority between several CDN. + * @class SteeringManifestFetcher + */ +export default class SteeringManifestFetcher { + private _settings: ISteeringManifestFetcherSettings; + private _pipelines: ITransportSteeringManifestPipeline; + + /** + * Construct a new SteeringManifestFetcher. + * @param {Object} pipelines - Transport pipelines used to perform the + * Content Steering Manifest loading and parsing operations. + * @param {Object} settings - Configure the `SteeringManifestFetcher`. + */ + constructor( + pipelines: ITransportSteeringManifestPipeline, + settings: ISteeringManifestFetcherSettings, + ) { + this._pipelines = pipelines; + this._settings = settings; + } + + /** + * (re-)Load the Content Steering Manifest. + * This method does not yet parse it, parsing will then be available through + * a callback available on the response. + * + * You can set an `url` on which that Content Steering Manifest will be + * requested. + * If not set, the regular Content Steering Manifest url - defined on the + * `SteeringManifestFetcher` instanciation - will be used instead. + * + * @param {string|undefined} url + * @param {Function} onRetry + * @param {Object} cancelSignal + * @returns {Promise} + */ + public async fetch( + url: string, + onRetry: (error: IPlayerError) => void, + cancelSignal: CancellationSignal, + ): Promise { + const pipelines = this._pipelines; + const backoffSettings = this._getBackoffSetting((err) => { + onRetry(errorSelector(err)); + }); + const callLoader = () => pipelines.loadSteeringManifest(url, cancelSignal); + const response = await scheduleRequestPromise( + callLoader, + backoffSettings, + cancelSignal, + ); + return (onWarnings: (error: IPlayerError[]) => void) => { + return this._parseSteeringManifest(response, onWarnings); + }; + } + + /** + * Parse an already loaded Content Steering Manifest. + * + * This method should be reserved for Content Steering Manifests for which no + * request has been done. + * In other cases, it's preferable to go through the `fetch` method, so + * information on the request can be used by the parsing process. + * @param {*} steeringManifest + * @param {Function} onWarnings + * @returns {Observable} + */ + public parse( + steeringManifest: unknown, + onWarnings: (error: IPlayerError[]) => void, + ): ISteeringManifest { + return this._parseSteeringManifest( + { responseData: steeringManifest, size: undefined, requestDuration: undefined }, + onWarnings, + ); + } + + /** + * Parse a Content Steering Manifest. + * @param {Object} loaded - Information about the loaded Content Steering Manifest. + * @param {Function} onWarnings + * @returns {Observable} + */ + private _parseSteeringManifest( + loaded: IRequestedData, + onWarnings: (error: IPlayerError[]) => void, + ): ISteeringManifest { + try { + return this._pipelines.parseSteeringManifest( + loaded, + function onTransportWarnings(errs) { + const warnings = errs.map((e) => formatParsingError(e)); + onWarnings(warnings); + }, + ); + } catch (err) { + throw formatParsingError(err); + } + + /** + * Format the given Error and emit it through `obs`. + * Either through a `"warning"` event, if `isFatal` is `false`, or through + * a fatal Observable error, if `isFatal` is set to `true`. + * @param {*} err + * @returns {Error} + */ + function formatParsingError(err: unknown): IPlayerError { + return formatError(err, { + defaultCode: "PIPELINE_PARSE_ERROR", + defaultReason: "Unknown error when parsing the Content Steering Manifest", + }); + } + } + + /** + * Construct "backoff settings" that can be used with a range of functions + * allowing to perform multiple request attempts + * @param {Function} onRetry + * @returns {Object} + */ + private _getBackoffSetting(onRetry: (err: unknown) => void): IBackoffSettings { + const { + DEFAULT_MAX_CONTENT_STEERING_MANIFEST_REQUEST_RETRY, + INITIAL_BACKOFF_DELAY_BASE, + MAX_BACKOFF_DELAY_BASE, + } = config.getCurrent(); + const { maxRetry: ogRegular } = this._settings; + const baseDelay = INITIAL_BACKOFF_DELAY_BASE.REGULAR; + const maxDelay = MAX_BACKOFF_DELAY_BASE.REGULAR; + const maxRetry = ogRegular ?? DEFAULT_MAX_CONTENT_STEERING_MANIFEST_REQUEST_RETRY; + return { onRetry, baseDelay, maxDelay, maxRetry }; + } +} diff --git a/src/core/fetchers/utils/schedule_request.ts b/src/core/fetchers/utils/schedule_request.ts index f5f9e74f710..1b72974718d 100644 --- a/src/core/fetchers/utils/schedule_request.ts +++ b/src/core/fetchers/utils/schedule_request.ts @@ -22,6 +22,8 @@ import getFuzzedDelay from "../../../utils/get_fuzzed_delay"; import getTimestamp from "../../../utils/monotonic_timestamp"; import noop from "../../../utils/noop"; import { RequestError } from "../../../utils/request"; +import type { ISyncOrAsyncValue } from "../../../utils/sync_or_async"; +import SyncOrAsync from "../../../utils/sync_or_async"; import type { CancellationSignal } from "../../../utils/task_canceller"; import TaskCanceller from "../../../utils/task_canceller"; import type CdnPrioritizer from "../cdn_prioritizer"; @@ -166,8 +168,17 @@ export async function scheduleRequestWithCdns( } const missedAttempts: Map = new Map(); - const initialCdnToRequest = getCdnToRequest(); - if (initialCdnToRequest === undefined) { + const cdnsResponse = getCdnToRequest(); + let initialCdnToRequest; + if (cdnsResponse.syncValue === null) { + initialCdnToRequest = await cdnsResponse.getValueAsAsync(); + } else { + initialCdnToRequest = cdnsResponse.syncValue; + } + + if (initialCdnToRequest === "no-http") { + initialCdnToRequest = null; + } else if (initialCdnToRequest === undefined) { throw new Error("No CDN to request"); } return requestCdn(initialCdnToRequest); @@ -175,25 +186,35 @@ export async function scheduleRequestWithCdns( /** * Returns what is now the most prioritary CDN to request the wanted resource. * - * A return value of `null` indicates that the resource can be requested + * A return value of `"no-http"` indicates that the resource can be requested * through another mean than by doing an HTTP request. * * A return value of `undefined` indicates that there's no CDN left to request * the resource. * @returns {Object|null|undefined} */ - function getCdnToRequest(): ICdnMetadata | null | undefined { + function getCdnToRequest(): ISyncOrAsyncValue { if (cdns === null) { const nullAttemptObject = missedAttempts.get(null); if (nullAttemptObject !== undefined && nullAttemptObject.isBlacklisted) { - return undefined; + return SyncOrAsync.createSync(undefined); } - return null; + return SyncOrAsync.createSync("no-http"); } else if (cdnPrioritizer === null) { - return getPrioritaryRequestableCdnFromSortedList(cdns); + return SyncOrAsync.createSync(getPrioritaryRequestableCdnFromSortedList(cdns)); } else { const prioritized = cdnPrioritizer.getCdnPreferenceForResource(cdns); - return getPrioritaryRequestableCdnFromSortedList(prioritized); + // TODO order by `blockedUntil` DESC if `missedAttempts` is not empty + if (prioritized.syncValue !== null) { + return SyncOrAsync.createSync( + getPrioritaryRequestableCdnFromSortedList(prioritized.syncValue), + ); + } + return SyncOrAsync.createAsync( + prioritized + .getValueAsAsync() + .then((v) => getPrioritaryRequestableCdnFromSortedList(v)), + ); } } @@ -264,13 +285,21 @@ export async function scheduleRequestWithCdns( * @returns {Promise} */ async function retryWithNextCdn(prevRequestError: unknown): Promise { - const nextCdn = getCdnToRequest(); + const currCdnResponse = getCdnToRequest(); + let nextCdn; + if (currCdnResponse.syncValue === null) { + nextCdn = await currCdnResponse.getValueAsAsync(); + } else { + nextCdn = currCdnResponse.syncValue; + } if (cancellationSignal.isCancelled()) { throw cancellationSignal.cancellationError; } - if (nextCdn === undefined) { + if (nextCdn === "no-http") { + nextCdn = null; + } else if (nextCdn === undefined) { throw prevRequestError; } @@ -310,15 +339,23 @@ export async function scheduleRequestWithCdns( const canceller = new TaskCanceller(); const unlinkCanceller = canceller.linkToSignal(cancellationSignal); return new Promise((res, rej) => { - /* eslint-disable-next-line @typescript-eslint/no-misused-promises */ cdnPrioritizer?.addEventListener( "priorityChange", - () => { - const updatedPrioritaryCdn = getCdnToRequest(); + /* eslint-disable-next-line @typescript-eslint/no-misused-promises */ + async () => { + const newCdnsResponse = getCdnToRequest(); + let updatedPrioritaryCdn; + if (newCdnsResponse.syncValue === null) { + updatedPrioritaryCdn = await newCdnsResponse.getValueAsAsync(); + } else { + updatedPrioritaryCdn = newCdnsResponse.syncValue; + } if (cancellationSignal.isCancelled()) { throw cancellationSignal.cancellationError; } - if (updatedPrioritaryCdn === undefined) { + if (updatedPrioritaryCdn === "no-http") { + updatedPrioritaryCdn = null; + } else if (updatedPrioritaryCdn === undefined) { return cleanAndReject(prevRequestError); } if (updatedPrioritaryCdn !== nextWantedCdn) { diff --git a/src/core/main/worker/content_preparer.ts b/src/core/main/worker/content_preparer.ts index 5c52970073c..190af4e16c6 100644 --- a/src/core/main/worker/content_preparer.ts +++ b/src/core/main/worker/content_preparer.ts @@ -122,12 +122,6 @@ export default class ContentPreparer { }, ); - const segmentFetcherCreator = new SegmentFetcherCreator( - dashPipelines, - context.segmentRetryOptions, - contentCanceller.signal, - ); - const trackChoiceSetter = new TrackChoiceSetter(); const [mediaSource, segmentSinksStore, workerTextSender] = @@ -151,7 +145,7 @@ export default class ContentPreparer { manifestFetcher, representationEstimator, segmentSinksStore, - segmentFetcherCreator, + segmentFetcherCreator: null, workerTextSender, trackChoiceSetter, }; @@ -185,8 +179,16 @@ export default class ContentPreparer { return; } manifest = man; + + const segmentFetcherCreator = new SegmentFetcherCreator( + dashPipelines, + manifest, + context.segmentRetryOptions, + contentCanceller.signal, + ); if (this._currentContent !== null) { this._currentContent.manifest = manifest; + this._currentContent.segmentFetcherCreator = segmentFetcherCreator; } checkIfReadyAndValidate(); }, @@ -346,8 +348,10 @@ export interface IPreparedContentData { /** * Allows to create `SegmentFetcher` which simplifies complex media segment * fetching. + * + * Set to `null` until the Manifest has been fetched. */ - segmentFetcherCreator: SegmentFetcherCreator; + segmentFetcherCreator: SegmentFetcherCreator | null; /** * Allows to store and update the wanted tracks and Representation inside that * track. diff --git a/src/core/main/worker/worker_main.ts b/src/core/main/worker/worker_main.ts index b902a1a749d..df18bb0054d 100644 --- a/src/core/main/worker/worker_main.ts +++ b/src/core/main/worker/worker_main.ts @@ -497,7 +497,11 @@ function loadOrReloadPreparedContent( > = new Map(); const preparedContent = contentPreparer.getCurrentContent(); - if (preparedContent === null || preparedContent.manifest === null) { + if ( + preparedContent === null || + preparedContent.manifest === null || + preparedContent.segmentFetcherCreator === null + ) { const error = new OtherError("NONE", "Loading content when none is prepared"); sendMessage({ type: WorkerMessageType.Error, diff --git a/src/default_config.ts b/src/default_config.ts index 2bcc9bc98a3..1b7702c0cd2 100644 --- a/src/default_config.ts +++ b/src/default_config.ts @@ -260,6 +260,21 @@ const DEFAULT_CONFIG = { */ DEFAULT_MAX_MANIFEST_REQUEST_RETRY: 4, + /** + * The default number of times a Content Steering Manifest request will be + * re-performed when loaded/refreshed if the request finishes on an error + * which justify an retry. + * + * Note that some errors do not use this counter: + * - if the error is not due to the xhr, no retry will be peformed + * - if the error is an HTTP error code, but not a 500-smthg or a 404, no + * retry will be performed. + * - if it has a high chance of being due to the user being offline, a + * separate counter is used (see DEFAULT_MAX_REQUESTS_RETRY_ON_OFFLINE). + * @type Number + */ + DEFAULT_MAX_CONTENT_STEERING_MANIFEST_REQUEST_RETRY: 4, + /** * Default delay, in seconds, during which a CDN will be "downgraded". * diff --git a/src/experimental/tools/VideoThumbnailLoader/video_thumbnail_loader.ts b/src/experimental/tools/VideoThumbnailLoader/video_thumbnail_loader.ts index 5d80995458e..b8b2ce67912 100644 --- a/src/experimental/tools/VideoThumbnailLoader/video_thumbnail_loader.ts +++ b/src/experimental/tools/VideoThumbnailLoader/video_thumbnail_loader.ts @@ -183,6 +183,7 @@ export default class VideoThumbnailLoader { const segmentFetcher = createSegmentFetcher( "video", loader.video, + // TODO implement ContentSteering for the VideoThumbnailLoader? null, // We don't care about the SegmentFetcher's lifecycle events {}, diff --git a/src/main_thread/init/media_source_content_initializer.ts b/src/main_thread/init/media_source_content_initializer.ts index 4693a479fde..cc3253412ac 100644 --- a/src/main_thread/init/media_source_content_initializer.ts +++ b/src/main_thread/init/media_source_content_initializer.ts @@ -364,6 +364,7 @@ export default class MediaSourceContentInitializer extends ContentInitializer { const segmentFetcherCreator = new SegmentFetcherCreator( transport, + manifest, segmentRequestOptions, initCanceller.signal, ); diff --git a/src/manifest/classes/manifest.ts b/src/manifest/classes/manifest.ts index 8b25d6040be..90df353271f 100644 --- a/src/manifest/classes/manifest.ts +++ b/src/manifest/classes/manifest.ts @@ -16,7 +16,7 @@ import { MediaError } from "../../errors"; import log from "../../log"; -import type { IParsedManifest } from "../../parsers/manifest"; +import type { IContentSteeringMetadata, IParsedManifest } from "../../parsers/manifest"; import type { ITrackType, IRepresentationFilter, IPlayerError } from "../../public_types"; import arrayFind from "../../utils/array_find"; import EventEmitter from "../../utils/event_emitter"; @@ -208,6 +208,8 @@ export default class Manifest */ public clockOffset: number | undefined; + public contentSteering: IContentSteeringMetadata | null; + /** * Data allowing to calculate the minimum and maximum seekable positions at * any given time. @@ -364,6 +366,7 @@ export default class Manifest this.suggestedPresentationDelay = parsedManifest.suggestedPresentationDelay; this.availabilityStartTime = parsedManifest.availabilityStartTime; this.publishTime = parsedManifest.publishTime; + this.contentSteering = parsedManifest.contentSteering; } /** @@ -627,6 +630,7 @@ export default class Manifest this.suggestedPresentationDelay = newManifest.suggestedPresentationDelay; this.transport = newManifest.transport; this.publishTime = newManifest.publishTime; + this.contentSteering = newManifest.contentSteering; let updatedPeriodsResult; if (updateType === MANIFEST_UPDATE_TYPE.Full) { diff --git a/src/parsers/SteeringManifest/DCSM/parse_dcsm.ts b/src/parsers/SteeringManifest/DCSM/parse_dcsm.ts new file mode 100644 index 00000000000..943b57106c8 --- /dev/null +++ b/src/parsers/SteeringManifest/DCSM/parse_dcsm.ts @@ -0,0 +1,67 @@ +/** + * Copyright 2015 CANAL+ Group + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ISteeringManifest } from "../types"; + +export default function parseDashContentSteeringManifest( + input: string | Partial>, +): [ISteeringManifest, Error[]] { + const warnings: Error[] = []; + let json; + if (typeof input === "string") { + json = JSON.parse(input) as Partial>; + } else { + json = input; + } + + if (json.VERSION !== 1) { + throw new Error("Unhandled DCSM version. Only `1` can be proccessed."); + } + + const initialPriorities = json["SERVICE-LOCATION-PRIORITY"]; + if (!Array.isArray(initialPriorities)) { + throw new Error("The DCSM's SERVICE-LOCATION-URI in in the wrong format"); + } else if (initialPriorities.length === 0) { + warnings.push( + new Error("The DCSM's SERVICE-LOCATION-URI should contain at least one element"), + ); + } + + const priorities: string[] = initialPriorities.filter( + (elt): elt is string => typeof elt === "string", + ); + if (priorities.length !== initialPriorities.length) { + warnings.push( + new Error("The DCSM's SERVICE-LOCATION-URI contains URI in a wrong format"), + ); + } + let lifetime = 300; + + if (typeof json.TTL === "number") { + lifetime = json.TTL; + } else if (json.TTL !== undefined) { + warnings.push(new Error("The DCSM's TTL in in the wrong format")); + } + + let reloadUri; + if (typeof json["RELOAD-URI"] === "string") { + reloadUri = json["RELOAD-URI"]; + } else if (json["RELOAD-URI"] !== undefined) { + warnings.push(new Error("The DCSM's RELOAD-URI in in the wrong format")); + } + + return [{ lifetime, reloadUri, priorities }, warnings]; +} diff --git a/src/parsers/SteeringManifest/index.ts b/src/parsers/SteeringManifest/index.ts new file mode 100644 index 00000000000..440de8879db --- /dev/null +++ b/src/parsers/SteeringManifest/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2015 CANAL+ Group + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ISteeringManifest } from "./types"; diff --git a/src/parsers/SteeringManifest/types.ts b/src/parsers/SteeringManifest/types.ts new file mode 100644 index 00000000000..9c3403d15ad --- /dev/null +++ b/src/parsers/SteeringManifest/types.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2015 CANAL+ Group + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface ISteeringManifest { + lifetime: number; + reloadUri?: string | undefined; + priorities: string[]; +} diff --git a/src/parsers/manifest/dash/common/parse_mpd.ts b/src/parsers/manifest/dash/common/parse_mpd.ts index 5664b5e88f0..a90eabb62b3 100644 --- a/src/parsers/manifest/dash/common/parse_mpd.ts +++ b/src/parsers/manifest/dash/common/parse_mpd.ts @@ -21,7 +21,7 @@ import arrayFind from "../../../../utils/array_find"; import isNullOrUndefined from "../../../../utils/is_null_or_undefined"; import getMonotonicTimeStamp from "../../../../utils/monotonic_timestamp"; import { getFilenameIndexInUrl } from "../../../../utils/resolve_url"; -import type { IParsedManifest } from "../../types"; +import type { IContentSteeringMetadata, IParsedManifest } from "../../types"; import type { IMPDIntermediateRepresentation, IPeriodIntermediateRepresentation, @@ -298,6 +298,17 @@ function parseCompleteIntermediateRepresentation( time: number; }; + let contentSteering: IContentSteeringMetadata | null = null; + if (rootChildren.contentSteering !== undefined) { + const { attributes } = rootChildren.contentSteering; + contentSteering = { + url: rootChildren.contentSteering.value, + defaultId: attributes.defaultServiceLocation, + queryBeforeStart: attributes.queryBeforeStart === true, + proxyUrl: attributes.proxyServerUrl, + }; + } + if ( rootAttributes.minimumUpdatePeriod !== undefined && rootAttributes.minimumUpdatePeriod >= 0 @@ -406,6 +417,7 @@ function parseCompleteIntermediateRepresentation( const parsedMPD: IParsedManifest = { availabilityStartTime, clockOffset: args.externalClockOffset, + contentSteering, isDynamic, isLive: isDynamic, isLastPeriodKnown, diff --git a/src/parsers/manifest/dash/common/resolve_base_urls.ts b/src/parsers/manifest/dash/common/resolve_base_urls.ts index 04920812e15..9312f48a125 100644 --- a/src/parsers/manifest/dash/common/resolve_base_urls.ts +++ b/src/parsers/manifest/dash/common/resolve_base_urls.ts @@ -36,7 +36,7 @@ export default function resolveBaseURLs( } const newBaseUrls: IResolvedBaseUrl[] = newBaseUrlsIR.map((ir) => { - return { url: ir.value }; + return { url: ir.value, serviceLocation: ir.attributes.serviceLocation }; }); if (currentBaseURLs.length === 0) { return newBaseUrls; diff --git a/src/parsers/manifest/dash/fast-js-parser/node_parsers/BaseURL.ts b/src/parsers/manifest/dash/fast-js-parser/node_parsers/BaseURL.ts index 63c68c16a4a..4f8b68b8320 100644 --- a/src/parsers/manifest/dash/fast-js-parser/node_parsers/BaseURL.ts +++ b/src/parsers/manifest/dash/fast-js-parser/node_parsers/BaseURL.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import isNullOrUndefined from "../../../../../utils/is_null_or_undefined"; import type { ITNode } from "../../../../../utils/xml-parser"; import type { IBaseUrlIntermediateRepresentation } from "../../node_parser_types"; import { textContent } from "./utils"; @@ -25,12 +26,23 @@ import { textContent } from "./utils"; * @returns {Array.} */ export default function parseBaseURL( - root: ITNode | string, + root: ITNode, ): [IBaseUrlIntermediateRepresentation | undefined, Error[]] { + const attributes: { serviceLocation?: string } = {}; const value = typeof root === "string" ? root : textContent(root.children); const warnings: Error[] = []; if (value === null || value.length === 0) { return [undefined, warnings]; } - return [{ value }, warnings]; + + for (const attributeName of Object.keys(root.attributes)) { + const attributeVal = root.attributes[attributeName]; + if (isNullOrUndefined(attributeVal)) { + continue; + } + if (attributeName === "serviceLocation") { + attributes.serviceLocation = attributeVal; + } + } + return [{ value, attributes }, warnings]; } diff --git a/src/parsers/manifest/dash/fast-js-parser/node_parsers/ContentSteering.ts b/src/parsers/manifest/dash/fast-js-parser/node_parsers/ContentSteering.ts new file mode 100644 index 00000000000..ab3ebdde3d9 --- /dev/null +++ b/src/parsers/manifest/dash/fast-js-parser/node_parsers/ContentSteering.ts @@ -0,0 +1,51 @@ +import isNullOrUndefined from "../../../../../utils/is_null_or_undefined"; +import type { ITNode } from "../../../../../utils/xml-parser"; +import type { IContentSteeringIntermediateRepresentation } from "../../node_parser_types"; +import { parseBoolean, textContent, ValueParser } from "./utils"; + +/** + * Parse an ContentSteering element into an ContentSteering intermediate + * representation. + * @param {Object} root - The ContentSteering root element. + * @returns {Array.} + */ +export default function parseContentSteering( + root: ITNode, +): [IContentSteeringIntermediateRepresentation | undefined, Error[]] { + const attributes: { + defaultServiceLocation?: string; + queryBeforeStart?: boolean; + proxyServerUrl?: string; + } = {}; + const value = typeof root === "string" ? root : textContent(root.children); + const warnings: Error[] = []; + if (value === null || value.length === 0) { + return [undefined, warnings]; + } + const parseValue = ValueParser(attributes, warnings); + for (const attributeName of Object.keys(root.attributes)) { + const attributeVal = root.attributes[attributeName]; + if (isNullOrUndefined(attributeVal)) { + continue; + } + switch (attributeName) { + case "defaultServiceLocation": + attributes.defaultServiceLocation = attributeVal; + break; + + case "queryBeforeStart": + parseValue(attributeVal, { + asKey: "queryBeforeStart", + parser: parseBoolean, + dashName: "queryBeforeStart", + }); + break; + + case "proxyServerUrl": + attributes.proxyServerUrl = attributeVal; + break; + } + } + + return [{ value, attributes }, warnings]; +} diff --git a/src/parsers/manifest/dash/fast-js-parser/node_parsers/MPD.ts b/src/parsers/manifest/dash/fast-js-parser/node_parsers/MPD.ts index 62ece7c7a09..bfa0a33925b 100644 --- a/src/parsers/manifest/dash/fast-js-parser/node_parsers/MPD.ts +++ b/src/parsers/manifest/dash/fast-js-parser/node_parsers/MPD.ts @@ -20,6 +20,7 @@ import type { ITNode } from "../../../../../utils/xml-parser"; import type { IBaseUrlIntermediateRepresentation, IContentProtectionIntermediateRepresentation, + IContentSteeringIntermediateRepresentation, IMPDAttributes, IMPDChildren, IMPDIntermediateRepresentation, @@ -28,6 +29,7 @@ import type { } from "../../node_parser_types"; import parseBaseURL from "./BaseURL"; import parseContentProtection from "./ContentProtection"; +import parseContentSteering from "./ContentSteering"; import { createPeriodIntermediateRepresentation } from "./Period"; import { parseDateTime, @@ -51,6 +53,7 @@ function parseMPDChildren( const periods: IPeriodIntermediateRepresentation[] = []; const utcTimings: IScheme[] = []; const contentProtections: IContentProtectionIntermediateRepresentation[] = []; + let contentSteering: IContentSteeringIntermediateRepresentation | undefined; let warnings: Error[] = []; for (let i = 0; i < mpdChildren.length; i++) { @@ -67,6 +70,13 @@ function parseMPDChildren( warnings = warnings.concat(baseURLWarnings); break; + case "ContentSteering": + const [contentSteeringObj, contentSteeringWarnings] = + parseContentSteering(currentNode); + contentSteering = contentSteeringObj; + warnings = warnings.concat(contentSteeringWarnings); + break; + case "Location": locations.push(textContent(currentNode.children)); break; @@ -97,7 +107,10 @@ function parseMPDChildren( break; } } - return [{ baseURLs, locations, periods, utcTimings, contentProtections }, warnings]; + return [ + { baseURLs, contentSteering, locations, periods, utcTimings, contentProtections }, + warnings, + ]; } /** diff --git a/src/parsers/manifest/dash/fast-js-parser/node_parsers/__tests__/AdaptationSet.test.ts b/src/parsers/manifest/dash/fast-js-parser/node_parsers/__tests__/AdaptationSet.test.ts index 4031da78286..d13ba5b8a8e 100644 --- a/src/parsers/manifest/dash/fast-js-parser/node_parsers/__tests__/AdaptationSet.test.ts +++ b/src/parsers/manifest/dash/fast-js-parser/node_parsers/__tests__/AdaptationSet.test.ts @@ -477,14 +477,17 @@ describe("DASH Node Parsers - AdaptationSet", () => { ]); }); - it("should correctly parse a non-empty baseURLs", () => { + it("should correctly parse a non-empty baseURL", () => { const element1 = parseXml( 'a', )[0] as ITNode; expect(createAdaptationSetIntermediateRepresentation(element1)).toEqual([ { attributes: {}, - children: { baseURLs: [{ value: "a" }], representations: [] }, + children: { + baseURLs: [{ attributes: { serviceLocation: "foo" }, value: "a" }], + representations: [], + }, }, [], ]); @@ -495,7 +498,10 @@ describe("DASH Node Parsers - AdaptationSet", () => { expect(createAdaptationSetIntermediateRepresentation(element2)).toEqual([ { attributes: {}, - children: { baseURLs: [{ value: "foo bar" }], representations: [] }, + children: { + baseURLs: [{ attributes: { serviceLocation: "4" }, value: "foo bar" }], + representations: [], + }, }, [], ]); @@ -509,7 +515,10 @@ describe("DASH Node Parsers - AdaptationSet", () => { { attributes: {}, children: { - baseURLs: [{ value: "a" }, { value: "b" }], + baseURLs: [ + { attributes: { serviceLocation: "" }, value: "a" }, + { attributes: { serviceLocation: "http://test.com" }, value: "b" }, + ], representations: [], }, }, diff --git a/src/parsers/manifest/dash/native-parser/node_parsers/BaseURL.ts b/src/parsers/manifest/dash/native-parser/node_parsers/BaseURL.ts index c7eb5068f43..5971aae80b1 100644 --- a/src/parsers/manifest/dash/native-parser/node_parsers/BaseURL.ts +++ b/src/parsers/manifest/dash/native-parser/node_parsers/BaseURL.ts @@ -25,10 +25,21 @@ import type { IBaseUrlIntermediateRepresentation } from "../../node_parser_types export default function parseBaseURL( root: Element, ): [IBaseUrlIntermediateRepresentation | undefined, Error[]] { + const attributes: { serviceLocation?: string } = {}; const value = root.textContent; const warnings: Error[] = []; if (value === null || value.length === 0) { return [undefined, warnings]; } - return [{ value }, warnings]; + for (let i = 0; i < root.attributes.length; i++) { + const attribute = root.attributes[i]; + + switch (attribute.name) { + case "serviceLocation": + attributes.serviceLocation = attribute.value; + break; + } + } + + return [{ value, attributes }, warnings]; } diff --git a/src/parsers/manifest/dash/native-parser/node_parsers/ContentSteering.ts b/src/parsers/manifest/dash/native-parser/node_parsers/ContentSteering.ts new file mode 100644 index 00000000000..1499e4449a9 --- /dev/null +++ b/src/parsers/manifest/dash/native-parser/node_parsers/ContentSteering.ts @@ -0,0 +1,63 @@ +/** + * Copyright 2015 CANAL+ Group + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { IContentSteeringIntermediateRepresentation } from "../../node_parser_types"; +import { parseBoolean, ValueParser } from "./utils"; + +/** + * Parse an ContentSteering element into an ContentSteering intermediate + * representation. + * @param {Element} root - The ContentSteering root element. + * @returns {Array.} + */ +export default function parseContentSteering( + root: Element, +): [IContentSteeringIntermediateRepresentation | undefined, Error[]] { + const attributes: { + defaultServiceLocation?: string; + queryBeforeStart?: boolean; + proxyServerUrl?: string; + } = {}; + const value = root.textContent; + const warnings: Error[] = []; + if (value === null || value.length === 0) { + return [undefined, warnings]; + } + const parseValue = ValueParser(attributes, warnings); + for (let i = 0; i < root.attributes.length; i++) { + const attribute = root.attributes[i]; + + switch (attribute.name) { + case "defaultServiceLocation": + attributes.defaultServiceLocation = attribute.value; + break; + + case "queryBeforeStart": + parseValue(attribute.value, { + asKey: "queryBeforeStart", + parser: parseBoolean, + dashName: "queryBeforeStart", + }); + break; + + case "proxyServerUrl": + attributes.proxyServerUrl = attribute.value; + break; + } + } + + return [{ value, attributes }, warnings]; +} diff --git a/src/parsers/manifest/dash/native-parser/node_parsers/MPD.ts b/src/parsers/manifest/dash/native-parser/node_parsers/MPD.ts index ed384111445..4e89593a11d 100644 --- a/src/parsers/manifest/dash/native-parser/node_parsers/MPD.ts +++ b/src/parsers/manifest/dash/native-parser/node_parsers/MPD.ts @@ -17,6 +17,7 @@ import type { IBaseUrlIntermediateRepresentation, IContentProtectionIntermediateRepresentation, + IContentSteeringIntermediateRepresentation, IMPDAttributes, IMPDChildren, IMPDIntermediateRepresentation, @@ -25,6 +26,7 @@ import type { } from "../../node_parser_types"; import parseBaseURL from "./BaseURL"; import parseContentProtection from "./ContentProtection"; +import parseContentSteering from "./ContentSteering"; import { createPeriodIntermediateRepresentation } from "./Period"; import { parseDateTime, parseDuration, parseScheme, ValueParser } from "./utils"; @@ -39,6 +41,7 @@ function parseMPDChildren(mpdChildren: NodeList): [IMPDChildren, Error[]] { const periods: IPeriodIntermediateRepresentation[] = []; const utcTimings: IScheme[] = []; const contentProtections: IContentProtectionIntermediateRepresentation[] = []; + let contentSteering: IContentSteeringIntermediateRepresentation | undefined; let warnings: Error[] = []; for (let i = 0; i < mpdChildren.length; i++) { @@ -53,6 +56,13 @@ function parseMPDChildren(mpdChildren: NodeList): [IMPDChildren, Error[]] { warnings = warnings.concat(baseURLWarnings); break; + case "ContentSteering": + const [contentSteeringObj, contentSteeringWarnings] = + parseContentSteering(currentNode); + contentSteering = contentSteeringObj; + warnings = warnings.concat(contentSteeringWarnings); + break; + case "Location": locations.push(currentNode.textContent === null ? "" : currentNode.textContent); break; @@ -83,7 +93,10 @@ function parseMPDChildren(mpdChildren: NodeList): [IMPDChildren, Error[]] { } } - return [{ baseURLs, locations, periods, utcTimings, contentProtections }, warnings]; + return [ + { baseURLs, contentSteering, locations, periods, utcTimings, contentProtections }, + warnings, + ]; } /** diff --git a/src/parsers/manifest/dash/native-parser/node_parsers/__tests__/AdaptationSet.test.ts b/src/parsers/manifest/dash/native-parser/node_parsers/__tests__/AdaptationSet.test.ts index 0ae4b692954..3845b392d9f 100644 --- a/src/parsers/manifest/dash/native-parser/node_parsers/__tests__/AdaptationSet.test.ts +++ b/src/parsers/manifest/dash/native-parser/node_parsers/__tests__/AdaptationSet.test.ts @@ -572,42 +572,56 @@ describe("DASH Node Parsers - AdaptationSet", () => { ]); }); - it("should correctly parse a non-empty baseURLs", () => { - const element1 = new DOMParser().parseFromString( - 'a', - "text/xml", - ).childNodes[0] as Element; + it("should correctly parse a non-empty baseURL", () => { + const element1 = new DOMParser() + // eslint-disable-next-line max-len + .parseFromString( + 'a', + "text/xml", + ).childNodes[0] as Element; expect(createAdaptationSetIntermediateRepresentation(element1)).toEqual([ { attributes: {}, - children: { baseURLs: [{ value: "a" }], representations: [] }, + children: { + baseURLs: [{ attributes: { serviceLocation: "foo" }, value: "a" }], + representations: [], + }, }, [], ]); const element2 = new DOMParser().parseFromString( + // eslint-disable-next-line max-len 'foo bar', "text/xml", ).childNodes[0] as Element; expect(createAdaptationSetIntermediateRepresentation(element2)).toEqual([ { attributes: {}, - children: { baseURLs: [{ value: "foo bar" }], representations: [] }, + children: { + baseURLs: [{ attributes: { serviceLocation: "4" }, value: "foo bar" }], + representations: [], + }, }, [], ]); }); it("should correctly parse multiple non-empty baseURLs", () => { - const element1 = new DOMParser().parseFromString( - 'ab', - "text/xml", - ).childNodes[0] as Element; + const element1 = new DOMParser() + // eslint-disable-next-line max-len + .parseFromString( + 'ab', + "text/xml", + ).childNodes[0] as Element; expect(createAdaptationSetIntermediateRepresentation(element1)).toEqual([ { attributes: {}, children: { - baseURLs: [{ value: "a" }, { value: "b" }], + baseURLs: [ + { attributes: { serviceLocation: "" }, value: "a" }, + { attributes: { serviceLocation: "http://test.com" }, value: "b" }, + ], representations: [], }, }, diff --git a/src/parsers/manifest/dash/node_parser_types.ts b/src/parsers/manifest/dash/node_parser_types.ts index 70083f2fff7..8bcde04e3ab 100644 --- a/src/parsers/manifest/dash/node_parser_types.ts +++ b/src/parsers/manifest/dash/node_parser_types.ts @@ -43,6 +43,11 @@ export interface IMPDChildren { * from the first encountered to the last encountered. */ baseURLs: IBaseUrlIntermediateRepresentation[]; + /** + * Information on a potential Content Steering Manifest linked to this + * content. + */ + contentSteering?: IContentSteeringIntermediateRepresentation | undefined; /** * Location(s) at which the Manifest can be refreshed. * @@ -382,6 +387,43 @@ export interface IBaseUrlIntermediateRepresentation { * This is the inner content of a BaseURL node. */ value: string; + + /** Attributes assiociated to the BaseURL node. */ + attributes: { + /** + * Potential value for a `serviceLocation` attribute, used in content + * steering mechanisms. + */ + serviceLocation?: string; + }; +} + +/** Intermediate representation for a ContentSteering node. */ +export interface IContentSteeringIntermediateRepresentation { + /** + * The Content Steering Manifest's URL. + * + * This is the inner content of a ContentSteering node. + */ + value: string; + + /** Attributes assiociated to the ContentSteering node. */ + attributes: { + /** Default ServiceLocation to be used. */ + defaultServiceLocation?: string; + /** + * If `true`, the Content Steering Manifest should be loaded before the + * first resources depending on it are loaded. + */ + queryBeforeStart?: boolean; + /** + * If set, a proxy URL has been configured. + * Requests for the Content Steering Manifest should actually go through + * this proxy, the node URL being added to an `url` query parameter + * alongside potential other query parameters. + */ + proxyServerUrl?: string; + }; } /** Intermediate representation for a Node following a "scheme" format. */ diff --git a/src/parsers/manifest/dash/wasm-parser/rs/events.rs b/src/parsers/manifest/dash/wasm-parser/rs/events.rs index 24157080065..4488d7d73f9 100644 --- a/src/parsers/manifest/dash/wasm-parser/rs/events.rs +++ b/src/parsers/manifest/dash/wasm-parser/rs/events.rs @@ -86,6 +86,9 @@ pub enum TagName { // -- Inside a -- /// Indicate a node SegmentUrl = 20, + + /// Indicate a node + ContentSteering = 21, } #[derive(PartialEq, Clone, Copy)] @@ -280,6 +283,12 @@ pub enum AttributeName { ServiceLocation = 72, // String + QueryBeforeStart = 73, // Boolean + + ProxyServerUrl = 74, // String + + DefaultServiceLocation = 75, + // SegmentTemplate EndNumber = 76, // f64 diff --git a/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs b/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs index 5ea5cf67ca0..0a315bcd89c 100644 --- a/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs +++ b/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs @@ -153,6 +153,20 @@ pub fn report_base_url_attrs(tag_bs: &quick_xml::events::BytesStart) { } } +pub fn report_content_steering_attrs(tag_bs: &quick_xml::events::BytesStart) { + for res_attr in tag_bs.attributes() { + match res_attr { + Ok(attr) => match attr.key.as_ref() { + b"defaultServiceLocation" => DefaultServiceLocation.try_report_as_string(&attr), + b"proxyServerUrl" => ProxyServerUrl.try_report_as_string(&attr), + b"queryBeforeStart" => QueryBeforeStart.try_report_as_bool(&attr), + _ => {} + }, + Err(err) => ParsingError::from(err).report_err(), + }; + } +} + pub fn report_segment_template_attrs(tag_bs: &quick_xml::events::BytesStart) { for res_attr in tag_bs.attributes() { match res_attr { diff --git a/src/parsers/manifest/dash/wasm-parser/rs/processor/mod.rs b/src/parsers/manifest/dash/wasm-parser/rs/processor/mod.rs index 6c10d82d847..3813c263cd7 100644 --- a/src/parsers/manifest/dash/wasm-parser/rs/processor/mod.rs +++ b/src/parsers/manifest/dash/wasm-parser/rs/processor/mod.rs @@ -112,6 +112,11 @@ impl MPDProcessor { attributes::report_base_url_attrs(&tag); self.process_base_url_element(); } + b"ContentSteering" => { + TagName::ContentSteering.report_tag_open(); + attributes::report_content_steering_attrs(&tag); + self.process_content_steering_element(); + } b"cenc:pssh" => self.process_cenc_element(), b"Location" => self.process_location_element(), b"Label" => self.process_label_element(), @@ -339,6 +344,47 @@ impl MPDProcessor { } } + fn process_content_steering_element(&mut self) { + // Count inner ContentSteering tags if it exists. + // Allowing to not close the current node when it is an inner that is closed + let mut inner_tag: u32 = 0; + + loop { + match self.read_next_event() { + Ok(Event::Text(t)) => { + if t.len() > 0 { + match t.unescape() { + Ok(unescaped) => AttributeName::Text.report(unescaped), + Err(err) => ParsingError::from(err).report_err(), + } + } + } + Ok(Event::Start(tag)) if tag.name().as_ref() == b"ContentSteering" => { + inner_tag += 1 + } + Ok(Event::End(tag)) if tag.name().as_ref() == b"ContentSteering" => { + if inner_tag > 0 { + inner_tag -= 1; + } else { + TagName::ContentSteering.report_tag_close(); + break; + } + } + Ok(Event::Eof) => { + ParsingError("Unexpected end of file in a ContentSteering.".to_owned()) + .report_err(); + break; + } + Err(e) => { + ParsingError::from(e).report_err(); + break; + } + _ => (), + } + self.reader_buf.clear(); + } + } + fn process_cenc_element(&mut self) { // Count inner cenc:pssh tags if it exists. // Allowing to not close the current node when it is an inner that is closed diff --git a/src/parsers/manifest/dash/wasm-parser/ts/generators/BaseURL.ts b/src/parsers/manifest/dash/wasm-parser/ts/generators/BaseURL.ts index 8cf6aeb25e8..dd01fd83ae9 100644 --- a/src/parsers/manifest/dash/wasm-parser/ts/generators/BaseURL.ts +++ b/src/parsers/manifest/dash/wasm-parser/ts/generators/BaseURL.ts @@ -31,8 +31,20 @@ export function generateBaseUrlAttrParser( ): IAttributeParser { const textDecoder = new TextDecoder(); return function onMPDAttribute(attr: AttributeName, ptr: number, len: number) { - if (attr === AttributeName.Text) { - baseUrlAttrs.value = parseString(textDecoder, linearMemory.buffer, ptr, len); + switch (attr) { + case AttributeName.Text: + baseUrlAttrs.value = parseString(textDecoder, linearMemory.buffer, ptr, len); + break; + + case AttributeName.ServiceLocation: { + baseUrlAttrs.attributes.serviceLocation = parseString( + textDecoder, + linearMemory.buffer, + ptr, + len, + ); + break; + } } }; } diff --git a/src/parsers/manifest/dash/wasm-parser/ts/generators/ContentSteering.ts b/src/parsers/manifest/dash/wasm-parser/ts/generators/ContentSteering.ts new file mode 100644 index 00000000000..09dde4035e0 --- /dev/null +++ b/src/parsers/manifest/dash/wasm-parser/ts/generators/ContentSteering.ts @@ -0,0 +1,71 @@ +/** + * Copyright 2015 CANAL+ Group + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { IContentSteeringIntermediateRepresentation } from "../../../node_parser_types"; +import type { IAttributeParser } from "../parsers_stack"; +import { AttributeName } from "../types"; +import { parseString } from "../utils"; + +/** + * Generate an "attribute parser" once inside a `ContentSteering` node. + * @param {Object} contentSteeringAttrs + * @param {WebAssembly.Memory} linearMemory + * @returns {Function} + */ +export function generateContentSteeringAttrParser( + contentSteeringAttrs: IContentSteeringIntermediateRepresentation, + linearMemory: WebAssembly.Memory, +): IAttributeParser { + const textDecoder = new TextDecoder(); + return function onMPDAttribute(attr: number, ptr: number, len: number) { + switch (attr) { + case AttributeName.Text: + contentSteeringAttrs.value = parseString( + textDecoder, + linearMemory.buffer, + ptr, + len, + ); + break; + + case AttributeName.DefaultServiceLocation: { + contentSteeringAttrs.attributes.defaultServiceLocation = parseString( + textDecoder, + linearMemory.buffer, + ptr, + len, + ); + break; + } + + case AttributeName.QueryBeforeStart: { + contentSteeringAttrs.attributes.queryBeforeStart = + new DataView(linearMemory.buffer).getUint8(0) === 0; + break; + } + + case AttributeName.ProxyServerUrl: { + contentSteeringAttrs.attributes.proxyServerUrl = parseString( + textDecoder, + linearMemory.buffer, + ptr, + len, + ); + break; + } + } + }; +} diff --git a/src/parsers/manifest/dash/wasm-parser/ts/generators/MPD.ts b/src/parsers/manifest/dash/wasm-parser/ts/generators/MPD.ts index 3ee379ca597..5148eac0fac 100644 --- a/src/parsers/manifest/dash/wasm-parser/ts/generators/MPD.ts +++ b/src/parsers/manifest/dash/wasm-parser/ts/generators/MPD.ts @@ -22,6 +22,7 @@ import { AttributeName, TagName } from "../types"; import { parseString } from "../utils"; import { generateBaseUrlAttrParser } from "./BaseURL"; import { generateContentProtectionAttrParser } from "./ContentProtection"; +import { generateContentSteeringAttrParser } from "./ContentSteering"; import { generatePeriodAttrParser, generatePeriodChildrenParser } from "./Period"; import { generateSchemeAttrParser } from "./Scheme"; @@ -51,6 +52,19 @@ export function generateMPDChildrenParser( break; } + case TagName.ContentSteering: { + const contentSteering = { value: "", attributes: {} }; + mpdChildren.contentSteering = contentSteering; + + const childrenParser = noop; // ContentSteering have no sub-element + const attributeParser = generateContentSteeringAttrParser( + contentSteering, + linearMemory, + ); + parsersStack.pushParsers(nodeId, childrenParser, attributeParser); + break; + } + case TagName.Period: { const period = { children: { adaptations: [], baseURLs: [], eventStreams: [] }, diff --git a/src/parsers/manifest/dash/wasm-parser/ts/types.ts b/src/parsers/manifest/dash/wasm-parser/ts/types.ts index 61b90e204e7..c53361720ca 100644 --- a/src/parsers/manifest/dash/wasm-parser/ts/types.ts +++ b/src/parsers/manifest/dash/wasm-parser/ts/types.ts @@ -108,6 +108,9 @@ export const enum TagName { /// Indicate a node SegmentUrl = 20, + + /// Indicate a node + ContentSteering = 21, } /** diff --git a/src/parsers/manifest/local/parse_local_manifest.ts b/src/parsers/manifest/local/parse_local_manifest.ts index 203b6fba814..a5889129cbe 100644 --- a/src/parsers/manifest/local/parse_local_manifest.ts +++ b/src/parsers/manifest/local/parse_local_manifest.ts @@ -57,6 +57,7 @@ export default function parseLocalManifest( return { availabilityStartTime: 0, + contentSteering: null, expired: localManifest.expired, transportType: "local", isDynamic: !isFinished, diff --git a/src/parsers/manifest/metaplaylist/metaplaylist_parser.ts b/src/parsers/manifest/metaplaylist/metaplaylist_parser.ts index ee761bc90b8..a57582fd5fa 100644 --- a/src/parsers/manifest/metaplaylist/metaplaylist_parser.ts +++ b/src/parsers/manifest/metaplaylist/metaplaylist_parser.ts @@ -336,6 +336,7 @@ function createManifest( const manifest = { availabilityStartTime: 0, clockOffset, + contentSteering: null, suggestedPresentationDelay: 10, periods, transportType: "metaplaylist", diff --git a/src/parsers/manifest/smooth/create_parser.ts b/src/parsers/manifest/smooth/create_parser.ts index 542c606fe72..2ee2c06c086 100644 --- a/src/parsers/manifest/smooth/create_parser.ts +++ b/src/parsers/manifest/smooth/create_parser.ts @@ -669,6 +669,7 @@ function createSmoothStreamingParser( availabilityStartTime: availabilityStartTime === undefined ? 0 : availabilityStartTime, clockOffset: serverTimeOffset, + contentSteering: null, isLive, isDynamic: isLive, isLastPeriodKnown: true, diff --git a/src/parsers/manifest/types.ts b/src/parsers/manifest/types.ts index 7f1969ac817..b67dd291e09 100644 --- a/src/parsers/manifest/types.ts +++ b/src/parsers/manifest/types.ts @@ -104,7 +104,8 @@ export interface ICdnMetadata { baseUrl: string; /** - * Identifier that might be re-used in other documents. + * Identifier that might be re-used in other documents, for example a + * Content Steering Manifest, to identify this CDN. */ id?: string | undefined; } @@ -400,4 +401,13 @@ export interface IParsedManifest { suggestedPresentationDelay?: number | undefined; /** URIs where the manifest can be refreshed by order of importance. */ uris?: string[] | undefined; + + contentSteering: IContentSteeringMetadata | null; +} + +export interface IContentSteeringMetadata { + url: string; + defaultId: string | undefined; + queryBeforeStart: boolean; + proxyUrl: string | undefined; } diff --git a/src/transports/dash/pipelines.ts b/src/transports/dash/pipelines.ts index dec800c7f2f..636043533e0 100644 --- a/src/transports/dash/pipelines.ts +++ b/src/transports/dash/pipelines.ts @@ -20,6 +20,10 @@ import generateManifestLoader from "../utils/generate_manifest_loader"; import generateManifestParser from "./manifest_parser"; import generateSegmentLoader from "./segment_loader"; import generateAudioVideoSegmentParser from "./segment_parser"; +import { + loadSteeringManifest, + parseSteeringManifest, +} from "./steering_manifest_pipeline"; import generateTextTrackLoader from "./text_loader"; import generateTextTrackParser from "./text_parser"; @@ -52,6 +56,7 @@ export default function (options: ITransportOptions): ITransportPipelines { parseSegment: audioVideoSegmentParser, }, text: { loadSegment: textTrackLoader, parseSegment: textTrackParser }, + steeringManifest: { loadSteeringManifest, parseSteeringManifest }, }; } diff --git a/src/transports/dash/steering_manifest_pipeline.ts b/src/transports/dash/steering_manifest_pipeline.ts new file mode 100644 index 00000000000..2589b52cd5e --- /dev/null +++ b/src/transports/dash/steering_manifest_pipeline.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2015 CANAL+ Group + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ISteeringManifest } from "../../parsers/SteeringManifest"; +/* eslint-disable-next-line max-len */ +import parseDashContentSteeringManifest from "../../parsers/SteeringManifest/DCSM/parse_dcsm"; +import request from "../../utils/request"; +import type { CancellationSignal } from "../../utils/task_canceller"; +import type { IRequestedData } from "../types"; + +/** + * Loads DASH's Content Steering Manifest. + * @param {string|null} url + * @param {Object} cancelSignal + * @returns {Promise} + */ +export async function loadSteeringManifest( + url: string, + cancelSignal: CancellationSignal, +): Promise> { + return request({ url, responseType: "text", cancelSignal }); +} + +/** + * Parses DASH's Content Steering Manifest. + * @param {Object} loadedSegment + * @param {Function} onWarnings + * @returns {Object} + */ +export function parseSteeringManifest( + { responseData }: IRequestedData, + onWarnings: (warnings: Error[]) => void, +): ISteeringManifest { + if ( + typeof responseData !== "string" && + (typeof responseData !== "object" || responseData === null) + ) { + throw new Error("Invalid loaded format for DASH's Content Steering Manifest."); + } + + const parsed = parseDashContentSteeringManifest(responseData); + if (parsed[1].length > 0) { + onWarnings(parsed[1]); + } + return parsed[0]; +} diff --git a/src/transports/local/pipelines.ts b/src/transports/local/pipelines.ts index 8600804848d..6f6f2e0b434 100644 --- a/src/transports/local/pipelines.ts +++ b/src/transports/local/pipelines.ts @@ -92,5 +92,6 @@ export default function getLocalManifestPipelines( audio: segmentPipeline, video: segmentPipeline, text: textTrackPipeline, + steeringManifest: null, }; } diff --git a/src/transports/metaplaylist/pipelines.ts b/src/transports/metaplaylist/pipelines.ts index 8689946209c..48bea7db69e 100644 --- a/src/transports/metaplaylist/pipelines.ts +++ b/src/transports/metaplaylist/pipelines.ts @@ -396,5 +396,6 @@ export default function (options: ITransportOptions): ITransportPipelines { audio: audioPipeline, video: videoPipeline, text: textTrackPipeline, + steeringManifest: null, }; } diff --git a/src/transports/smooth/pipelines.ts b/src/transports/smooth/pipelines.ts index d73e5074450..e0b35afbf92 100644 --- a/src/transports/smooth/pipelines.ts +++ b/src/transports/smooth/pipelines.ts @@ -412,5 +412,6 @@ export default function (transportOptions: ITransportOptions): ITransportPipelin audio: audioVideoPipeline, video: audioVideoPipeline, text: textTrackPipeline, + steeringManifest: null, }; } diff --git a/src/transports/types.ts b/src/transports/types.ts index 35e6ce2731b..974f2aa1449 100644 --- a/src/transports/types.ts +++ b/src/transports/types.ts @@ -17,6 +17,7 @@ import type { IInbandEvent } from "../core/types"; import type { IManifest, ISegment } from "../manifest"; import type { ICdnMetadata } from "../parsers/manifest"; +import type { ISteeringManifest } from "../parsers/SteeringManifest"; import type { ITrackType, ILoadedManifestFormat, @@ -58,6 +59,20 @@ export interface ITransportPipelines { >; /** Functions allowing to load an parse text (e.g. subtitles) segments. */ text: ISegmentPipeline; + /** + * Functions allowing to load and parse a Content Steering Manifest for this + * transport. + * + * A Content Steering Manifest is an external document allowing to obtain the + * current priority between multiple available CDN. A Content Steering + * Manifest also may or may not be available depending on the content. You + * might know its availability by parsing the content's Manifest or any other + * resource. + * + * `null` if the notion of a Content Steering Manifest does not exist for this + * transport or if it does but it isn't handled right now. + */ + steeringManifest: ITransportSteeringManifestPipeline | null; } /** Functions allowing to load and parse the Manifest. */ @@ -175,6 +190,71 @@ export interface IManifestLoaderOptions { connectionTimeout?: number | undefined; } +/** + * Functions allowing to load and parse a potential Content Steering Manifest, + * which gives an order of preferred CDN to serve the content. + */ +export interface ITransportSteeringManifestPipeline { + /** + * "Loader" of the Steering Manifest pipeline, allowing to request a Steering + * Manifest so it can later be parsed by the `parseSteeringManifest` function. + * + * @param {string} url - URL of the Steering Manifest we want to load. + * @param {CancellationSignal} cancellationSignal - Signal which will allow to + * cancel the loading operation if the Steering Manifest is not needed anymore + * (for example, if the content has just been stopped). + * When cancelled, the promise returned by this function will reject with a + * `CancellationError`. + * @returns {Promise.} - Promise emitting the loaded Steering + * Manifest, that then can be parsed through the `parseSteeringManifest` + * function. + * + * Rejects in two cases: + * - The loading operation has been cancelled through the `cancelSignal` + * given in argument. + * In that case, this Promise will reject with a `CancellationError`. + * - The loading operation failed, most likely due to a request error. + * In that case, this Promise will reject with the corresponding Error. + */ + loadSteeringManifest: ( + url: string, + cancelSignal: CancellationSignal, + ) => Promise>>>; + + /** + * "Parser" of the Steering Manifest pipeline, allowing to parse a loaded + * Steering Manifest so it can be exploited by the rest of the RxPlayer's + * logic. + * + * @param {Object} data - Response obtained from the `loadSteeringManifest` + * function. + * @param {Function} onWarnings - Callbacks called when minor Steering + * Manifest parsing errors are found. + * @param {CancellationSignal} cancelSignal - Cancellation signal which will + * allow to abort the parsing operation if you do not want the Steering + * Manifest anymore. + * + * That cancellationSignal can be triggered at any time, such as: + * - after a warning is received + * - while a request scheduled through the `scheduleRequest` argument is + * pending. + * + * `parseSteeringManifest` will interrupt all operations if the signal has + * been triggered in one of those scenarios, and will automatically reject + * with the corresponding `CancellationError` instance. + * @returns {Object | Promise.} - Returns the Steering Manifest data. + * Throws if a fatal error happens while doing so. + * + * If this error is due to a cancellation (indicated through the + * `cancelSignal` argument), then the rejected error should be the + * corresponding `CancellationError` instance. + */ + parseSteeringManifest: ( + data: IRequestedData, + onWarnings: (warnings: Error[]) => void, + ) => ISteeringManifest; +} + /** Functions allowing to load and parse segments of any type. */ export interface ISegmentPipeline { loadSegment: ISegmentLoader; @@ -371,6 +451,13 @@ export interface IManifestParserResult { url?: string | undefined; } +export interface IDASHContentSteeringManifest { + VERSION: number; // REQUIRED, must be an integer + TTL?: number; // REQUIRED, number of seconds + ["RELOAD-URI"]?: string; // OPTIONAL, URI + ["SERVICE-LOCATION-PRIORITY"]: string[]; // REQUIRED, array of ServiceLocation +} + /** * Allow the parser to ask for loading supplementary ressources while still * profiting from the same retries and error management than the loader.