From aa1e9ef698f868c6487555378b37a7a73d5efd05 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 ++++++++++++++++-- .../fetchers/segment/segment_queue_creator.ts | 25 +- src/core/fetchers/steering_manifest/index.ts | 9 + .../steering_manifest_fetcher.ts | 160 +++++++++++ src/core/fetchers/utils/schedule_request.ts | 65 ++++- src/core/main/worker/content_preparer.ts | 24 +- src/core/main/worker/worker_main.ts | 6 +- src/default_config.ts | 15 + .../video_thumbnail_loader.ts | 1 + .../init/media_source_content_initializer.ts | 9 +- .../classes/__tests__/manifest.test.ts | 9 + src/manifest/classes/manifest.ts | 6 +- .../SteeringManifest/DCSM/parse_dcsm.ts | 51 ++++ src/parsers/SteeringManifest/index.ts | 1 + 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 | 43 +++ src/transports/local/pipelines.ts | 1 + src/transports/metaplaylist/pipelines.ts | 1 + src/transports/smooth/pipelines.ts | 1 + src/transports/types.ts | 87 ++++++ 44 files changed, 1281 insertions(+), 79 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 0000000000..e9c71e4ffa --- /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 195d113473..246e1cce6b 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_queue_creator.ts b/src/core/fetchers/segment/segment_queue_creator.ts index 454bad041b..04fb03a2aa 100644 --- a/src/core/fetchers/segment/segment_queue_creator.ts +++ b/src/core/fetchers/segment/segment_queue_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 CmcdDataBuilder from "../../cmcd"; @@ -59,20 +60,26 @@ export default class SegmentQueueCreator { private _cmcdDataBuilder: CmcdDataBuilder | null; /** - * @param {Object} transport - * @param {Object} options - * @param {Object} cancelSignal + * @param {Object} args */ constructor( - transport: ITransportPipelines, - cmcdDataBuilder: CmcdDataBuilder | null, - options: ISegmentQueueCreatorBackoffOptions, + { + transportPipelines, + manifest, + cmcdDataBuilder, + backoffOptions, + }: { + transportPipelines: ITransportPipelines; + manifest: IManifest; + cmcdDataBuilder: CmcdDataBuilder | null; + backoffOptions: ISegmentQueueCreatorBackoffOptions; + }, cancelSignal: CancellationSignal, ) { - const cdnPrioritizer = new CdnPrioritizer(cancelSignal); + const cdnPrioritizer = new CdnPrioritizer(manifest, transportPipelines, cancelSignal); const { MIN_CANCELABLE_PRIORITY, MAX_HIGH_PRIORITY_LEVEL } = config.getCurrent(); - this._transport = transport; + this._transport = transportPipelines; this._prioritizer = new TaskPrioritizer({ prioritySteps: { high: MAX_HIGH_PRIORITY_LEVEL, @@ -80,7 +87,7 @@ export default class SegmentQueueCreator { }, }); this._cdnPrioritizer = cdnPrioritizer; - this._backoffOptions = options; + this._backoffOptions = backoffOptions; this._cmcdDataBuilder = cmcdDataBuilder; } diff --git a/src/core/fetchers/steering_manifest/index.ts b/src/core/fetchers/steering_manifest/index.ts new file mode 100644 index 0000000000..a123768bb6 --- /dev/null +++ b/src/core/fetchers/steering_manifest/index.ts @@ -0,0 +1,9 @@ +import type { + ISteeringManifestFetcherSettings, + ISteeringManifestParser, +} from "./steering_manifest_fetcher"; + +import SteeringManifestFetcher from "./steering_manifest_fetcher"; + +export default SteeringManifestFetcher; +export type { 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 0000000000..36698dd80a --- /dev/null +++ b/src/core/fetchers/steering_manifest/steering_manifest_fetcher.ts @@ -0,0 +1,160 @@ +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 9362ccdf6a..1b72974718 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 92a12af54d..bf2adbc002 100644 --- a/src/core/main/worker/content_preparer.ts +++ b/src/core/main/worker/content_preparer.ts @@ -129,13 +129,6 @@ export default class ContentPreparer { }, ); - const segmentQueueCreator = new SegmentQueueCreator( - dashPipelines, - cmcdDataBuilder, - context.segmentRetryOptions, - contentCanceller.signal, - ); - const trackChoiceSetter = new TrackChoiceSetter(); const [mediaSource, segmentSinksStore, workerTextSender] = @@ -160,7 +153,7 @@ export default class ContentPreparer { manifestFetcher, representationEstimator, segmentSinksStore, - segmentQueueCreator, + segmentQueueCreator: null, workerTextSender, trackChoiceSetter, }; @@ -194,8 +187,19 @@ export default class ContentPreparer { return; } manifest = man; + + const segmentQueueCreator = new SegmentQueueCreator( + { + transportPipelines: dashPipelines, + cmcdDataBuilder, + manifest, + backoffOptions: context.segmentRetryOptions, + }, + contentCanceller.signal, + ); if (this._currentContent !== null) { this._currentContent.manifest = manifest; + this._currentContent.segmentQueueCreator = segmentQueueCreator; } checkIfReadyAndValidate(); }, @@ -361,8 +365,10 @@ export interface IPreparedContentData { /** * Allows to create `SegmentQueue` which simplifies complex media segment * fetching. + * + * Set to `null` until the Manifest has been fetched. */ - segmentQueueCreator: SegmentQueueCreator; + segmentQueueCreator: SegmentQueueCreator | 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 d51327b6ca..52a77a91d7 100644 --- a/src/core/main/worker/worker_main.ts +++ b/src/core/main/worker/worker_main.ts @@ -503,7 +503,11 @@ function loadOrReloadPreparedContent( > = new Map(); const preparedContent = contentPreparer.getCurrentContent(); - if (preparedContent === null || preparedContent.manifest === null) { + if ( + preparedContent === null || + preparedContent.manifest === null || + preparedContent.segmentQueueCreator === 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 5878e6460f..6b88fe9955 100644 --- a/src/default_config.ts +++ b/src/default_config.ts @@ -254,6 +254,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 8c268c753e..3b06124d57 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({ bufferType: "video", pipeline: loader.video, + // TODO implement ContentSteering for the VideoThumbnailLoader? cdnPrioritizer: null, cmcdDataBuilder: null, requestOptions: { diff --git a/src/main_thread/init/media_source_content_initializer.ts b/src/main_thread/init/media_source_content_initializer.ts index 5a8d88b30b..01b76ed5e6 100644 --- a/src/main_thread/init/media_source_content_initializer.ts +++ b/src/main_thread/init/media_source_content_initializer.ts @@ -440,9 +440,12 @@ export default class MediaSourceContentInitializer extends ContentInitializer { ); const segmentQueueCreator = new SegmentQueueCreator( - transport, - this._cmcdDataBuilder, - segmentRequestOptions, + { + transportPipelines: transport, + cmcdDataBuilder: this._cmcdDataBuilder, + manifest, + backoffOptions: segmentRequestOptions, + }, initCanceller.signal, ); diff --git a/src/manifest/classes/__tests__/manifest.test.ts b/src/manifest/classes/__tests__/manifest.test.ts index 9cd9b78778..67b2e7732b 100644 --- a/src/manifest/classes/__tests__/manifest.test.ts +++ b/src/manifest/classes/__tests__/manifest.test.ts @@ -78,6 +78,7 @@ describe("Manifest - Manifest", () => { }, }, periods: [], + contentSteering: null, }; const Manifest = (await vi.importActual("../manifest")).default as typeof IManifest; @@ -124,6 +125,7 @@ describe("Manifest - Manifest", () => { }, }, periods: [period1, period2], + contentSteering: null, }; const fakePeriod = vi.fn((period: IPeriod) => { @@ -182,6 +184,7 @@ describe("Manifest - Manifest", () => { }, }, periods: [period1, period2], + contentSteering: null, }; const representationFilter = function () { @@ -241,6 +244,7 @@ describe("Manifest - Manifest", () => { }, }, periods: [period1, period2], + contentSteering: null, }; const fakePeriod = vi.fn((period: IParsedPeriod): IPeriod => { @@ -299,6 +303,7 @@ describe("Manifest - Manifest", () => { }, }, periods: [period1, period2], + contentSteering: null, }; const fakePeriod = vi.fn( @@ -379,6 +384,7 @@ describe("Manifest - Manifest", () => { }, suggestedPresentationDelay: 99, uris: ["url1", "url2"], + contentSteering: null, }; const fakePeriod = vi.fn( @@ -461,6 +467,7 @@ describe("Manifest - Manifest", () => { }, }, periods: [oldPeriod1, oldPeriod2], + contentSteering: null, suggestedPresentationDelay: 99, uris: ["url1", "url2"], }; @@ -492,6 +499,7 @@ describe("Manifest - Manifest", () => { time: 10, }, }, + contentSteering: null, uris: [], }; const manifest2 = new Manifest(oldManifestArgs2, {}, []); @@ -546,6 +554,7 @@ describe("Manifest - Manifest", () => { }, suggestedPresentationDelay: 99, uris: ["url1", "url2"], + contentSteering: null, }; const Manifest = (await vi.importActual("../manifest")).default as typeof IManifest; diff --git a/src/manifest/classes/manifest.ts b/src/manifest/classes/manifest.ts index 122744ef0e..0377384cc6 100644 --- a/src/manifest/classes/manifest.ts +++ b/src/manifest/classes/manifest.ts @@ -17,7 +17,7 @@ import { MediaError } from "../../errors"; import log from "../../log"; import { getCodecsWithUnknownSupport } from "../../main_thread/init/utils/update_manifest_codec_support"; -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"; @@ -212,6 +212,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. @@ -374,6 +376,7 @@ export default class Manifest this.suggestedPresentationDelay = parsedManifest.suggestedPresentationDelay; this.availabilityStartTime = parsedManifest.availabilityStartTime; this.publishTime = parsedManifest.publishTime; + this.contentSteering = parsedManifest.contentSteering; } /** @@ -660,6 +663,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 0000000000..ae441f0074 --- /dev/null +++ b/src/parsers/SteeringManifest/DCSM/parse_dcsm.ts @@ -0,0 +1,51 @@ +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 0000000000..8c27c6222c --- /dev/null +++ b/src/parsers/SteeringManifest/index.ts @@ -0,0 +1 @@ +export type { ISteeringManifest } from "./types"; diff --git a/src/parsers/SteeringManifest/types.ts b/src/parsers/SteeringManifest/types.ts new file mode 100644 index 0000000000..9c3403d15a --- /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 486c6d497e..ab32a111cd 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/url-utils"; -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 @@ -420,6 +431,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 ed0e46c3df..c59359d8a6 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 63c68c16a4..4f8b68b832 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 0000000000..ab3ebdde3d --- /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 59d82eca7f..5c970387b0 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++) { @@ -68,6 +71,13 @@ function parseMPDChildren( break; } + case "ContentSteering": + const [contentSteeringObj, contentSteeringWarnings] = + parseContentSteering(currentNode); + contentSteering = contentSteeringObj; + warnings = warnings.concat(contentSteeringWarnings); + break; + case "Location": locations.push(textContent(currentNode.children)); break; @@ -101,7 +111,10 @@ function parseMPDChildren( } } } - 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 4031da7828..d13ba5b8a8 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 c7eb5068f4..5971aae80b 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 0000000000..1499e4449a --- /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 e51eaf1a75..2579677ba8 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++) { @@ -54,6 +57,13 @@ function parseMPDChildren(mpdChildren: NodeList): [IMPDChildren, Error[]] { 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; @@ -87,7 +97,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 0ae4b69295..3845b392d9 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 70083f2fff..8bcde04e3a 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 be48c4b579..59c4bad61f 100644 --- a/src/parsers/manifest/dash/wasm-parser/rs/events.rs +++ b/src/parsers/manifest/dash/wasm-parser/rs/events.rs @@ -89,6 +89,9 @@ pub enum TagName { /// Indicate a