From 60a9f652a57c4f707a3f06455db4e7a11fbe83d2 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 13 Oct 2022 17:11:55 +0200 Subject: [PATCH] Revert "Remove ContentSteering logic to just keep better Cdn priorization for now" This reverts commit 167c5ec7770c3274be6da40c7832e4048afeb19a. --- src/Content_Steering.md | 90 +++++++ src/core/fetchers/cdn_prioritizer.ts | 226 +++++++++++++++++- .../segment/segment_fetcher_creator.ts | 4 +- src/core/fetchers/steering_manifest/index.ts | 26 ++ .../steering_manifest_fetcher.ts | 184 ++++++++++++++ src/core/fetchers/utils/schedule_request.ts | 34 ++- src/core/init/initialize_media_source.ts | 1 + src/default_config.ts | 15 ++ .../video_thumbnail_loader.ts | 1 + src/manifest/manifest.ts | 9 +- .../SteeringManifest/DCSM/parse_dcsm.ts | 67 ++++++ src/parsers/SteeringManifest/index.ts | 18 ++ src/parsers/SteeringManifest/types.ts | 21 ++ src/parsers/manifest/dash/common/parse_mpd.ts | 16 +- .../manifest/dash/common/resolve_base_urls.ts | 3 +- .../dash/js-parser/node_parsers/BaseURL.ts | 13 +- .../js-parser/node_parsers/ContentSteering.ts | 63 +++++ .../dash/js-parser/node_parsers/MPD.ts | 12 +- .../__tests__/AdaptationSet.test.ts | 12 +- .../manifest/dash/node_parser_types.ts | 42 ++++ .../manifest/dash/wasm-parser/rs/events.rs | 11 +- .../wasm-parser/rs/processor/attributes.rs | 14 ++ .../dash/wasm-parser/rs/processor/mod.rs | 42 ++++ .../dash/wasm-parser/ts/generators/BaseURL.ts | 8 + .../ts/generators/ContentSteering.ts | 59 +++++ .../dash/wasm-parser/ts/generators/MPD.ts | 12 + .../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 | 8 +- .../dash/steering_manifest_pipeline.ts | 61 +++++ src/transports/local/pipelines.ts | 3 +- src/transports/metaplaylist/pipelines.ts | 3 +- src/transports/smooth/pipelines.ts | 3 +- src/transports/types.ts | 88 +++++++ src/utils/sync_or_async.ts | 72 ++++++ 38 files changed, 1225 insertions(+), 34 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/js-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 create mode 100644 src/utils/sync_or_async.ts diff --git a/src/Content_Steering.md b/src/Content_Steering.md new file mode 100644 index 00000000000..3db14d9b4d2 --- /dev/null +++ b/src/Content_Steering.md @@ -0,0 +1,90 @@ +# 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 78eedb164da..eb6a3150189 100644 --- a/src/core/fetchers/cdn_prioritizer.ts +++ b/src/core/fetchers/cdn_prioritizer.ts @@ -15,16 +15,39 @@ */ import config from "../../config"; -import { ICdnMetadata } from "../../parsers/manifest"; +import { formatError } from "../../errors"; +import log from "../../log"; +import Manifest from "../../manifest"; +import { + ICdnMetadata, + IContentSteeringMetadata, +} from "../../parsers/manifest"; +import { ISteeringManifest } from "../../parsers/SteeringManifest"; import { IPlayerError } from "../../public_types"; +import { ITransportPipelines } from "../../transports"; import arrayFindIndex from "../../utils/array_find_index"; +import arrayIncludes from "../../utils/array_includes"; import EventEmitter from "../../utils/event_emitter"; -import { CancellationSignal } from "../../utils/task_canceller"; +import createSharedReference, { + ISharedReference, +} from "../../utils/reference"; +import SyncOrAsync, { + ISyncOrAsyncValue, +} from "../../utils/sync_or_async"; +import TaskCanceller, { + CancellationError, + CancellationSignal, +} from "../../utils/task_canceller"; +import SteeringManifestFetcher from "./steering_manifest"; /** * Class signaling the priority between multiple CDN available for any given * resource. * + * 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 @@ -33,6 +56,16 @@ import { CancellationSignal } from "../../utils/task_canceller"; * @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 @@ -57,12 +90,90 @@ 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 : ISharedReference<"not-ready" | "ready" | "disposed">; + + /** + * @param {Object} manifest + * @param {Object} transport * @param {Object} destroySignal */ - constructor(destroySignal : CancellationSignal) { + constructor( + manifest : Manifest, + 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, + { maxRetryOffline: undefined, + maxRetryRegular: 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 = createSharedReference("ready"); + } else { + const readyState = manifest.contentSteering.queryBeforeStart ? "not-ready" : + "ready"; + this._readyState = createSharedReference(readyState); + this._autoRefreshSteeringManifest(steeringManifestFetcher, + manifest.contentSteering); + } + } else { + this._readyState = createSharedReference("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); } @@ -84,20 +195,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) + ); } /** @@ -116,7 +245,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 = window.setTimeout(() => { const newIndex = indexOfMetadata(this._downgradedCdnList.metadata, metadata); @@ -150,7 +280,33 @@ export default class CdnPrioritizer extends EventEmitter private _innerGetCdnPreferenceForResource( everyCdnForResource : ICdnMetadata[] ) : ICdnMetadata[] { - const [allowedInOrder, downgradedInOrder] = everyCdnForResource + 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(c => c.id === elt.id && c.baseUrl === elt.baseUrl)) @@ -164,6 +320,58 @@ 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 = window.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 */ diff --git a/src/core/fetchers/segment/segment_fetcher_creator.ts b/src/core/fetchers/segment/segment_fetcher_creator.ts index dd0fb785b85..25a65e5557e 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 Manifest from "../../../manifest"; import { ISegmentPipeline, ITransportPipelines, @@ -90,10 +91,11 @@ export default class SegmentFetcherCreator { */ constructor( transport : ITransportPipelines, + manifest : Manifest, 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(); diff --git a/src/core/fetchers/steering_manifest/index.ts b/src/core/fetchers/steering_manifest/index.ts new file mode 100644 index 00000000000..f591e5c1589 --- /dev/null +++ b/src/core/fetchers/steering_manifest/index.ts @@ -0,0 +1,26 @@ +/** + * 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..f74e41690cd --- /dev/null +++ b/src/core/fetchers/steering_manifest/steering_manifest_fetcher.ts @@ -0,0 +1,184 @@ +/** + * 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 { ISteeringManifest } from "../../../parsers/SteeringManifest"; +import { IPlayerError } from "../../../public_types"; +import { + IRequestedData, + ITransportSteeringManifestPipeline, +} from "../../../transports"; +import { CancellationSignal } from "../../../utils/task_canceller"; +import errorSelector from "../utils/error_selector"; +import { + IBackoffSettings, + 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. */ + maxRetryRegular : number | undefined; + /** Maximum number of time a request be retried when the user is offline. */ + maxRetryOffline : 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, + DEFAULT_MAX_REQUESTS_RETRY_ON_OFFLINE, + INITIAL_BACKOFF_DELAY_BASE, + MAX_BACKOFF_DELAY_BASE } = config.getCurrent(); + const { maxRetryRegular : ogRegular, + maxRetryOffline : ogOffline } = this._settings; + const baseDelay = INITIAL_BACKOFF_DELAY_BASE.REGULAR; + const maxDelay = MAX_BACKOFF_DELAY_BASE.REGULAR; + const maxRetryRegular = ogRegular ?? + DEFAULT_MAX_CONTENT_STEERING_MANIFEST_REQUEST_RETRY; + const maxRetryOffline = ogOffline ?? DEFAULT_MAX_REQUESTS_RETRY_ON_OFFLINE; + return { onRetry, + baseDelay, + maxDelay, + maxRetryRegular, + maxRetryOffline }; + } +} diff --git a/src/core/fetchers/utils/schedule_request.ts b/src/core/fetchers/utils/schedule_request.ts index e67024060f5..012f0af88dc 100644 --- a/src/core/fetchers/utils/schedule_request.ts +++ b/src/core/fetchers/utils/schedule_request.ts @@ -26,6 +26,9 @@ import { ICdnMetadata } from "../../../parsers/manifest"; import cancellableSleep from "../../../utils/cancellable_sleep"; import getFuzzedDelay from "../../../utils/get_fuzzed_delay"; import noop from "../../../utils/noop"; +import SyncOrAsync, { + ISyncOrAsyncValue, +} from "../../../utils/sync_or_async"; import TaskCanceller, { CancellationSignal, } from "../../../utils/task_canceller"; @@ -204,7 +207,9 @@ export async function scheduleRequestWithCdns( } const missedAttempts : Map = new Map(); - const initialCdnToRequest = getCdnToRequest(); + const cdnsResponse = getCdnToRequest(); + const initialCdnToRequest = cdnsResponse.syncValue ?? + await cdnsResponse.getValueAsAsync(); if (initialCdnToRequest === undefined) { throw new Error("No CDN to request"); } @@ -220,18 +225,25 @@ export async function scheduleRequestWithCdns( * 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(null); } 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))); } } @@ -313,7 +325,9 @@ export async function scheduleRequestWithCdns( * @returns {Promise} */ async function retryWithNextCdn(prevRequestError : unknown) : Promise { - const nextCdn = getCdnToRequest(); + const currCdnResponse = getCdnToRequest(); + const nextCdn = currCdnResponse.syncValue ?? + await currCdnResponse.getValueAsAsync(); if (cancellationSignal.isCancelled) { throw cancellationSignal.cancellationError; @@ -361,8 +375,10 @@ export async function scheduleRequestWithCdns( const canceller = new TaskCanceller({ cancelOn: cancellationSignal }); return new Promise((res, rej) => { /* eslint-disable-next-line @typescript-eslint/no-misused-promises */ - cdnPrioritizer?.addEventListener("priorityChange", () => { - const updatedPrioritaryCdn = getCdnToRequest(); + cdnPrioritizer?.addEventListener("priorityChange", async () => { + const newCdnsResponse = getCdnToRequest(); + const updatedPrioritaryCdn = newCdnsResponse.syncValue ?? + await newCdnsResponse.getValueAsAsync(); if (cancellationSignal.isCancelled) { throw cancellationSignal.cancellationError; } diff --git a/src/core/init/initialize_media_source.ts b/src/core/init/initialize_media_source.ts index e159f334c8c..8e7fd5c257c 100644 --- a/src/core/init/initialize_media_source.ts +++ b/src/core/init/initialize_media_source.ts @@ -267,6 +267,7 @@ export default function InitializeOnMediaSource( maxRetryRegular: segmentRequestOptions.regularError, maxRetryOffline: segmentRequestOptions.offlineError }; const segmentFetcherCreator = new SegmentFetcherCreator(transport, + manifest, requestOptions, playbackCanceller.signal); diff --git a/src/default_config.ts b/src/default_config.ts index 357d31be6fd..d4bf3e5bdcd 100644 --- a/src/default_config.ts +++ b/src/default_config.ts @@ -395,6 +395,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 0392fa0cdad..8d9104d023e 100644 --- a/src/experimental/tools/VideoThumbnailLoader/video_thumbnail_loader.ts +++ b/src/experimental/tools/VideoThumbnailLoader/video_thumbnail_loader.ts @@ -170,6 +170,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/manifest/manifest.ts b/src/manifest/manifest.ts index 0894bd2f713..fdc4f90b32f 100644 --- a/src/manifest/manifest.ts +++ b/src/manifest/manifest.ts @@ -15,7 +15,10 @@ */ import { MediaError } from "../errors"; -import { IParsedManifest } from "../parsers/manifest"; +import { + IContentSteeringMetadata, + IParsedManifest, +} from "../parsers/manifest"; import { IPlayerError, IRepresentationFilter, @@ -242,6 +245,8 @@ export default class Manifest extends EventEmitter { */ public clockOffset : number | undefined; + public contentSteering : IContentSteeringMetadata | null; + /** * Data allowing to calculate the minimum and maximum seekable positions at * any given time. @@ -381,6 +386,7 @@ export default class Manifest extends EventEmitter { this.suggestedPresentationDelay = parsedManifest.suggestedPresentationDelay; this.availabilityStartTime = parsedManifest.availabilityStartTime; this.publishTime = parsedManifest.publishTime; + this.contentSteering = parsedManifest.contentSteering; if (supplementaryImageTracks.length > 0) { this._addSupplementaryImageAdaptations(supplementaryImageTracks); } @@ -725,6 +731,7 @@ export default class Manifest extends EventEmitter { this.suggestedPresentationDelay = newManifest.suggestedPresentationDelay; this.transport = newManifest.transport; this.publishTime = newManifest.publishTime; + this.contentSteering = newManifest.contentSteering; if (updateType === MANIFEST_UPDATE_TYPE.Full) { this._timeBounds = newManifest._timeBounds; diff --git a/src/parsers/SteeringManifest/DCSM/parse_dcsm.ts b/src/parsers/SteeringManifest/DCSM/parse_dcsm.ts new file mode 100644 index 00000000000..becaa8b01fd --- /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 { 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..5246b359fe5 --- /dev/null +++ b/src/parsers/SteeringManifest/index.ts @@ -0,0 +1,18 @@ +/** + * 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..4b0a2a7332e --- /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 03e472826e6..b12abc8b7e5 100644 --- a/src/parsers/manifest/dash/common/parse_mpd.ts +++ b/src/parsers/manifest/dash/common/parse_mpd.ts @@ -19,7 +19,10 @@ import log from "../../../../log"; import Manifest from "../../../../manifest"; import arrayFind from "../../../../utils/array_find"; import { getFilenameIndexInUrl } from "../../../../utils/resolve_url"; -import { IParsedManifest } from "../../types"; +import { + IContentSteeringMetadata, + IParsedManifest, +} from "../../types"; import { IMPDIntermediateRepresentation, IPeriodIntermediateRepresentation, @@ -273,6 +276,16 @@ function parseCompleteIntermediateRepresentation( livePosition : number | undefined; 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) { @@ -367,6 +380,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 147f04d1f8d..35ca7c494fc 100644 --- a/src/parsers/manifest/dash/common/resolve_base_urls.ts +++ b/src/parsers/manifest/dash/common/resolve_base_urls.ts @@ -36,7 +36,8 @@ 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/js-parser/node_parsers/BaseURL.ts b/src/parsers/manifest/dash/js-parser/node_parsers/BaseURL.ts index 03da0658c02..80678ab46c1 100644 --- a/src/parsers/manifest/dash/js-parser/node_parsers/BaseURL.ts +++ b/src/parsers/manifest/dash/js-parser/node_parsers/BaseURL.ts @@ -25,11 +25,22 @@ import { 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 }, + 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/js-parser/node_parsers/ContentSteering.ts b/src/parsers/manifest/dash/js-parser/node_parsers/ContentSteering.ts new file mode 100644 index 00000000000..fe4a9bda870 --- /dev/null +++ b/src/parsers/manifest/dash/js-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 { 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/js-parser/node_parsers/MPD.ts b/src/parsers/manifest/dash/js-parser/node_parsers/MPD.ts index 6330c715297..6c50e820d62 100644 --- a/src/parsers/manifest/dash/js-parser/node_parsers/MPD.ts +++ b/src/parsers/manifest/dash/js-parser/node_parsers/MPD.ts @@ -16,6 +16,7 @@ import { IBaseUrlIntermediateRepresentation, + IContentSteeringIntermediateRepresentation, IMPDAttributes, IMPDChildren, IMPDIntermediateRepresentation, @@ -23,6 +24,7 @@ import { IScheme, } from "../../node_parser_types"; import parseBaseURL from "./BaseURL"; +import parseContentSteering from "./ContentSteering"; import { createPeriodIntermediateRepresentation, } from "./Period"; @@ -45,6 +47,7 @@ function parseMPDChildren( const locations : string[] = []; const periods : IPeriodIntermediateRepresentation[] = []; const utcTimings : IScheme[] = []; + let contentSteering : IContentSteeringIntermediateRepresentation | undefined; let warnings : Error[] = []; for (let i = 0; i < mpdChildren.length; i++) { @@ -61,6 +64,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(currentNode.textContent === null ? "" : @@ -82,7 +92,7 @@ function parseMPDChildren( } } - return [ { baseURLs, locations, periods, utcTimings }, + return [ { baseURLs, contentSteering, locations, periods, utcTimings }, warnings ]; } diff --git a/src/parsers/manifest/dash/js-parser/node_parsers/__tests__/AdaptationSet.test.ts b/src/parsers/manifest/dash/js-parser/node_parsers/__tests__/AdaptationSet.test.ts index f56b05e431f..caf53cf96ad 100644 --- a/src/parsers/manifest/dash/js-parser/node_parsers/__tests__/AdaptationSet.test.ts +++ b/src/parsers/manifest/dash/js-parser/node_parsers/__tests__/AdaptationSet.test.ts @@ -365,7 +365,8 @@ describe("DASH Node Parsers - AdaptationSet", () => { .toEqual([ { attributes: {}, - children: { baseURLs: [{ value: "a" }], + children: { baseURLs: [{ attributes: { serviceLocation: "foo" }, + value: "a" }], representations: [] }, }, [], @@ -381,7 +382,8 @@ describe("DASH Node Parsers - AdaptationSet", () => { .toEqual([ { attributes: {}, - children: { baseURLs: [{ value: "foo bar" }], + children: { baseURLs: [{ attributes: { serviceLocation: "4" }, + value: "foo bar" }], representations: [] }, }, [], @@ -397,8 +399,10 @@ describe("DASH Node Parsers - AdaptationSet", () => { .toEqual([ { attributes: {}, - children: { baseURLs: [ { value: "a" }, - { value: "b" } ], + children: { 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 7b32016b848..a756d714196 100644 --- a/src/parsers/manifest/dash/node_parser_types.ts +++ b/src/parsers/manifest/dash/node_parser_types.ts @@ -41,6 +41,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. * @@ -368,6 +373,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 075470bf9fe..620d5c76f42 100644 --- a/src/parsers/manifest/dash/wasm-parser/rs/events.rs +++ b/src/parsers/manifest/dash/wasm-parser/rs/events.rs @@ -90,6 +90,9 @@ pub enum TagName { /// Indicate a node SegmentUrl = 20, + + /// Indicate a node + ContentSteering = 21 } #[derive(PartialEq, Clone, Copy)] @@ -278,10 +281,16 @@ pub enum AttributeName { /// format: the browser's `DOMParser` API needs to know all potential /// namespaces that will appear in it. Namespace = 70, - + Label = 71, // String ServiceLocation = 72, // String + + QueryBeforeStart = 73, // Boolean + + ProxyServerUrl = 74, // String + + DefaultServiceLocation = 75, } impl TagName { 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 886c80c5388..9e9d6282fec 100644 --- a/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs +++ b/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs @@ -150,6 +150,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 { + b"serviceLocation" => ServiceLocation.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 bd5989ee1c1..aa272a51189 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(), @@ -335,6 +340,43 @@ 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.unescaped() { + Ok(unescaped) => AttributeName::Text.report(unescaped), + Err(err) => ParsingError::from(err).report_err(), + } + }, + Ok(Event::Start(tag)) if tag.name() == b"ContentSteering" => inner_tag += 1, + Ok(Event::End(tag)) if tag.name() == 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 d52adda4e11..646597b0493 100644 --- a/src/parsers/manifest/dash/wasm-parser/ts/generators/BaseURL.ts +++ b/src/parsers/manifest/dash/wasm-parser/ts/generators/BaseURL.ts @@ -35,6 +35,14 @@ export function generateBaseUrlAttrParser( 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..d638d6e13fa --- /dev/null +++ b/src/parsers/manifest/dash/wasm-parser/ts/generators/ContentSteering.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 { IContentSteeringIntermediateRepresentation } from "../../../node_parser_types"; +import { 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.ServiceLocation: { + 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 d51533713f7..85fa304b37d 100644 --- a/src/parsers/manifest/dash/wasm-parser/ts/generators/MPD.ts +++ b/src/parsers/manifest/dash/wasm-parser/ts/generators/MPD.ts @@ -29,6 +29,7 @@ import { } from "../types"; import { parseString } from "../utils"; import { generateBaseUrlAttrParser } from "./BaseURL"; +import { generateContentSteeringAttrParser } from "./ContentSteering"; import { generatePeriodAttrParser, generatePeriodChildrenParser, @@ -62,6 +63,17 @@ 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: [], diff --git a/src/parsers/manifest/dash/wasm-parser/ts/types.ts b/src/parsers/manifest/dash/wasm-parser/ts/types.ts index 571bb9a15af..6fc5aefb8c9 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 d2fdf3e76ab..70fb3bd6144 100644 --- a/src/parsers/manifest/local/parse_local_manifest.ts +++ b/src/parsers/manifest/local/parse_local_manifest.ts @@ -54,6 +54,7 @@ export default function parseLocalManifest( .map(period => parsePeriod(period, { periodIdGenerator })); 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 ddc97266449..d6d9de86b1c 100644 --- a/src/parsers/manifest/metaplaylist/metaplaylist_parser.ts +++ b/src/parsers/manifest/metaplaylist/metaplaylist_parser.ts @@ -306,6 +306,7 @@ function createManifest( manifests[manifests.length - 1].isLastPeriodKnown); 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 94d839dab23..2a609ed1017 100644 --- a/src/parsers/manifest/smooth/create_parser.ts +++ b/src/parsers/manifest/smooth/create_parser.ts @@ -650,6 +650,7 @@ function createSmoothStreamingParser( 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 076e4941673..73252a94e6a 100644 --- a/src/parsers/manifest/types.ts +++ b/src/parsers/manifest/types.ts @@ -90,7 +90,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; } @@ -377,4 +378,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 24619cd1b08..a83ae9b7143 100644 --- a/src/transports/dash/pipelines.ts +++ b/src/transports/dash/pipelines.ts @@ -27,6 +27,10 @@ import { 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"; @@ -57,7 +61,9 @@ export default function(options : ITransportOptions) : ITransportPipelines { text: { loadSegment: textTrackLoader, parseSegment: textTrackParser }, image: { loadSegment: imageLoader, - parseSegment: imageParser } }; + parseSegment: imageParser }, + 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..0d8ad372a3c --- /dev/null +++ b/src/transports/dash/steering_manifest_pipeline.ts @@ -0,0 +1,61 @@ +/** + * 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 { ISteeringManifest } from "../../parsers/SteeringManifest"; +/* eslint-disable-next-line max-len */ +import parseDashContentSteeringManifest from "../../parsers/SteeringManifest/DCSM/parse_dcsm"; +import request from "../../utils/request"; +import { CancellationSignal } from "../../utils/task_canceller"; +import { 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 01c4ecb1502..14f3a780c33 100644 --- a/src/transports/local/pipelines.ts +++ b/src/transports/local/pipelines.ts @@ -96,5 +96,6 @@ export default function getLocalManifestPipelines( audio: segmentPipeline, video: segmentPipeline, text: textTrackPipeline, - image: imageTrackPipeline }; + image: imageTrackPipeline, + steeringManifest: null }; } diff --git a/src/transports/metaplaylist/pipelines.ts b/src/transports/metaplaylist/pipelines.ts index 498ec2ab9c9..55b6a134d13 100644 --- a/src/transports/metaplaylist/pipelines.ts +++ b/src/transports/metaplaylist/pipelines.ts @@ -421,5 +421,6 @@ export default function(options : ITransportOptions): ITransportPipelines { audio: audioPipeline, video: videoPipeline, text: textTrackPipeline, - image: imageTrackPipeline }; + image: imageTrackPipeline, + steeringManifest: null }; } diff --git a/src/transports/smooth/pipelines.ts b/src/transports/smooth/pipelines.ts index 6d1b470f46d..e6292b95ba8 100644 --- a/src/transports/smooth/pipelines.ts +++ b/src/transports/smooth/pipelines.ts @@ -529,5 +529,6 @@ export default function(transportOptions : ITransportOptions) : ITransportPipeli audio: audioVideoPipeline, video: audioVideoPipeline, text: textTrackPipeline, - image: imageTrackPipeline }; + image: imageTrackPipeline, + steeringManifest: null }; } diff --git a/src/transports/types.ts b/src/transports/types.ts index 4e44519df33..98b91eba4ba 100644 --- a/src/transports/types.ts +++ b/src/transports/types.ts @@ -24,6 +24,7 @@ import Manifest, { Representation, } from "../manifest"; import { ICdnMetadata } from "../parsers/manifest"; +import { ISteeringManifest } from "../parsers/SteeringManifest"; import { IBifThumbnail, ILoadedManifestFormat, @@ -66,6 +67,21 @@ export interface ITransportPipelines { /** Functions allowing to load an parse image (e.g. thumbnails) segments. */ image : 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. */ @@ -212,6 +228,71 @@ export interface IManifestLoaderOptions { timeout? : 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< TLoadedFormat, @@ -405,6 +486,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. diff --git a/src/utils/sync_or_async.ts b/src/utils/sync_or_async.ts new file mode 100644 index 00000000000..ac8ae7871c9 --- /dev/null +++ b/src/utils/sync_or_async.ts @@ -0,0 +1,72 @@ +/** + * 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. + */ + +/** + * Type wrapping an underlying value that might either be obtained synchronously + * (a "sync" value) or asynchronously by awaiting a Promise (an "async" value). + * + * This type was created instead of just relying on Promises everytime, to + * avoid the necessity of always having the overhead and more complex + * always-async behavior of a Promise for a value that might be in most time + * obtainable synchronously. + * + * @example + * ```ts + * const val1 = SyncOrAsync.createAsync(Promise.resolve("foo")); + * const val2 = SyncOrAsync.createSync("bar"); + * + * async function logVal(val : ISyncOrAsyncValue) : void { + * // The following syntax allows to only await asynchronous values + * console.log(val.syncValue ?? await val.getValueAsAsync()); + * } + * + * logVal(val1); + * logVal(val2); + * + * // Here this will first log in the console "bar" directly and synchronously. + * // Then asychronously through a microtask (as Promises and awaited values + * // always are), "foo" will be logged. + * ``` + */ +export interface ISyncOrAsyncValue { + /** + * Set to the underlying value in the case where it was set synchronously. + * Set to `null` if the value is set asynchronously. + */ + syncValue : T | null; + /** + * Obtain the value asynchronously. + * This works even when the value is actually set synchronously, by embedding it + * value in a Promise. + */ + getValueAsAsync() : Promise; +} + +export default { + createSync(val : T) : ISyncOrAsyncValue { + return { + syncValue: val, + getValueAsAsync() { return Promise.resolve(val); }, + }; + }, + + createAsync(val : Promise) : ISyncOrAsyncValue { + return { + syncValue: null, + getValueAsAsync() { return val; }, + }; + }, +};