From d313b985611d7f8a8cd4937a2455abc5d3284b86 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 9 Mar 2021 13:28:16 +0100 Subject: [PATCH] remove RxJS code from the transports code After doing a proof-of-concept looking at how some parts of the code looks like without RxJS (#916), this is a first functional proposal which looks good enough to me to be merged. It removes all RxJS code from the `transports` code in `src/transports`. As a reminder, the reasons for doing this are: 1. Observables are complicated and full of implicit behaviors (lazily running, sync or async, unsubscribing automatically after the last unsubscription etc.) which is difficult to reason about, especially for a newcomer. Things like exploiting schedulers through the `deferSubscriptions` util to work-around some subtle potential race-conditions, or merging Observables in a specific order for similar reasons, are ugly hacks that are difficult to explain to someone not familiar with that code. Even for us with multiple years of experience with it, we sometimes struggle with it. 2. Promises, event listeners - and direct callbacks in general - are generally much more explicit and most developpers (at least JS/TS devs) are familiar with them. 3. Call stacks are close to inexploitable when using RxJS. 4. Promises provide async/await syntax which can improve drastically the readability of our async-heavy code, which for the moment suffer from callback hells almost everywhere. However, I'm still not sure if this wish (getting rid of RxJS) is shared by other maintainers and/or contributors, so it is still only a proposal. Thoughts? --- src/core/api/option_utils.ts | 6 +- .../manifest/get_manifest_backoff_options.ts | 50 - .../fetchers/manifest/manifest_fetcher.ts | 355 +++++-- .../fetchers/segment/create_segment_loader.ts | 250 ----- .../segment/get_segment_backoff_options.ts | 45 - .../segment/prioritized_segment_fetcher.ts | 2 +- src/core/fetchers/segment/segment_fetcher.ts | 437 +++++--- .../segment/segment_fetcher_creator.ts | 4 +- .../utils/create_request_scheduler.ts | 56 - .../fetchers/utils/try_urls_with_backoff.ts | 214 ++-- .../representation/representation_stream.ts | 27 +- .../VideoThumbnailLoader/create_request.ts | 52 +- .../get_initialized_source_buffer.ts | 59 +- .../VideoThumbnailLoader/load_segments.ts | 27 +- .../tools/VideoThumbnailLoader/push_data.ts | 14 +- .../VideoThumbnailLoader/thumbnail_loader.ts | 23 +- .../get_duration_from_manifest.ts | 101 +- .../add_segment_integrity_checks_to_loader.ts | 95 +- src/transports/dash/image_pipelines.ts | 54 +- src/transports/dash/init_segment_loader.ts | 113 ++- .../dash/low_latency_segment_loader.ts | 120 +-- src/transports/dash/manifest_parser.ts | 158 ++- src/transports/dash/pipelines.ts | 20 +- src/transports/dash/segment_loader.ts | 193 ++-- src/transports/dash/segment_parser.ts | 27 +- src/transports/dash/text_loader.ts | 87 +- src/transports/dash/text_parser.ts | 55 +- src/transports/local/pipelines.ts | 40 +- src/transports/local/segment_loader.ts | 122 ++- src/transports/local/segment_parser.ts | 16 +- src/transports/local/text_parser.ts | 52 +- .../metaplaylist/manifest_loader.ts | 30 +- src/transports/metaplaylist/pipelines.ts | 271 +++-- src/transports/smooth/pipelines.ts | 264 ++--- src/transports/smooth/segment_loader.ts | 211 ++-- src/transports/smooth/utils.ts | 13 + src/transports/types.ts | 956 +++++++++++------- .../utils/call_custom_manifest_loader.ts | 129 ++- .../utils/generate_manifest_loader.ts | 42 +- .../utils/return_parsed_manifest.ts | 47 - src/utils/cancellable_sleep.ts | 51 + src/utils/id_generator.ts | 3 +- src/utils/request/fetch.ts | 315 +++--- src/utils/request/index.ts | 12 +- src/utils/request/xhr.ts | 270 ++--- src/utils/rx-from_cancellable_promise.ts | 67 ++ src/utils/task_canceller.ts | 326 ++++++ .../integration/scenarios/initial_playback.js | 8 +- tests/integration/scenarios/manifest_error.js | 15 + .../utils/launch_tests_for_content.js | 2 +- 50 files changed, 3326 insertions(+), 2580 deletions(-) delete mode 100644 src/core/fetchers/manifest/get_manifest_backoff_options.ts delete mode 100644 src/core/fetchers/segment/create_segment_loader.ts delete mode 100644 src/core/fetchers/segment/get_segment_backoff_options.ts delete mode 100644 src/core/fetchers/utils/create_request_scheduler.ts delete mode 100644 src/transports/utils/return_parsed_manifest.ts create mode 100644 src/utils/cancellable_sleep.ts create mode 100644 src/utils/rx-from_cancellable_promise.ts create mode 100644 src/utils/task_canceller.ts diff --git a/src/core/api/option_utils.ts b/src/core/api/option_utils.ts index c52e1ed15a..8cfe162e7f 100644 --- a/src/core/api/option_utils.ts +++ b/src/core/api/option_utils.ts @@ -25,7 +25,7 @@ import { IRepresentationFilter } from "../../manifest"; import { CustomManifestLoader, CustomSegmentLoader, - ILoadedManifest, + ILoadedManifestFormat, ITransportOptions as IParsedTransportOptions, } from "../../transports"; import arrayIncludes from "../../utils/array_includes"; @@ -84,7 +84,7 @@ export interface ITransportOptions { */ checkMediaSegmentIntegrity? : boolean; /** Manifest object that will be used initially. */ - initialManifest? : ILoadedManifest; + initialManifest? : ILoadedManifestFormat; /** Custom implementation for performing Manifest requests. */ manifestLoader? : CustomManifestLoader; /** Possible custom URL pointing to a shorter form of the Manifest. */ @@ -272,7 +272,7 @@ interface IParsedLoadVideoOptionsBase { url : string | undefined; transport : string; autoPlay : boolean; - initialManifest : ILoadedManifest | undefined; + initialManifest : ILoadedManifestFormat | undefined; keySystems : IKeySystemOption[]; lowLatencyMode : boolean; minimumManifestUpdateInterval : number; diff --git a/src/core/fetchers/manifest/get_manifest_backoff_options.ts b/src/core/fetchers/manifest/get_manifest_backoff_options.ts deleted file mode 100644 index 1c8c80333c..0000000000 --- a/src/core/fetchers/manifest/get_manifest_backoff_options.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * 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 { IBackoffOptions } from "../utils/try_urls_with_backoff"; - -const { DEFAULT_MAX_MANIFEST_REQUEST_RETRY, - DEFAULT_MAX_REQUESTS_RETRY_ON_OFFLINE, - INITIAL_BACKOFF_DELAY_BASE, - MAX_BACKOFF_DELAY_BASE } = config; - -/** - * Parse config to replace missing manifest backoff options. - * @param {Object} backoffOptions - * @returns {Object} - */ -export default function getManifestBackoffOptions( - { maxRetryRegular, - maxRetryOffline, - lowLatencyMode }: { maxRetryRegular? : number; - maxRetryOffline? : number; - lowLatencyMode : boolean; } -) : IBackoffOptions { - const baseDelay = lowLatencyMode ? INITIAL_BACKOFF_DELAY_BASE.LOW_LATENCY : - INITIAL_BACKOFF_DELAY_BASE.REGULAR; - const maxDelay = lowLatencyMode ? MAX_BACKOFF_DELAY_BASE.LOW_LATENCY : - MAX_BACKOFF_DELAY_BASE.REGULAR; - return { - baseDelay, - maxDelay, - maxRetryRegular: maxRetryRegular !== undefined ? maxRetryRegular : - DEFAULT_MAX_MANIFEST_REQUEST_RETRY, - maxRetryOffline: maxRetryOffline !== undefined ? - maxRetryOffline : - DEFAULT_MAX_REQUESTS_RETRY_ON_OFFLINE, - }; -} diff --git a/src/core/fetchers/manifest/manifest_fetcher.ts b/src/core/fetchers/manifest/manifest_fetcher.ts index 4134148b0a..b2fdf32f8b 100644 --- a/src/core/fetchers/manifest/manifest_fetcher.ts +++ b/src/core/fetchers/manifest/manifest_fetcher.ts @@ -14,18 +14,11 @@ * limitations under the License. */ +import PPromise from "pinkie"; import { - merge as observableMerge, Observable, - of as observableOf, - Subject, } from "rxjs"; -import { - catchError, - finalize, - map, - mergeMap, -} from "rxjs/operators"; +import config from "../../../config"; import { formatError, ICustomError, @@ -33,21 +26,22 @@ import { import log from "../../../log"; import Manifest from "../../../manifest"; import { - ILoaderDataLoadedValue, - IManifestLoaderArguments, - IManifestLoaderFunction, - IManifestResolverFunction, + IRequestedData, ITransportManifestPipeline, ITransportPipelines, } from "../../../transports"; -import tryCatch$ from "../../../utils/rx-try_catch"; -import createRequestScheduler from "../utils/create_request_scheduler"; +import assert from "../../../utils/assert"; +import TaskCanceller from "../../../utils/task_canceller"; import errorSelector from "../utils/error_selector"; import { - IBackoffOptions, - tryRequestObservableWithBackoff, + IBackoffSettings, + tryRequestPromiseWithBackoff, } from "../utils/try_urls_with_backoff"; -import getManifestBackoffOptions from "./get_manifest_backoff_options"; + +const { DEFAULT_MAX_MANIFEST_REQUEST_RETRY, + DEFAULT_MAX_REQUESTS_RETRY_ON_OFFLINE, + INITIAL_BACKOFF_DELAY_BASE, + MAX_BACKOFF_DELAY_BASE } = config; /** What will be sent once parsed. */ export interface IManifestFetcherParsedResult { @@ -106,7 +100,7 @@ export interface IManifestFetcherParserOptions { } /** Options used by `createManifestFetcher`. */ -export interface IManifestFetcherBackoffOptions { +export interface IManifestFetcherSettings { /** * Whether the content is played in a low-latency mode. * This has an impact on default backoff delays. @@ -137,66 +131,121 @@ export interface IManifestFetcherBackoffOptions { * ``` */ export default class ManifestFetcher { - private _backoffOptions : IBackoffOptions; + private _settings : IManifestFetcherSettings; private _manifestUrl : string | undefined; private _pipelines : ITransportManifestPipeline; /** - * @param {string | undefined} url - * @param {Object} pipelines - * @param {Object} backoffOptions + * Construct a new ManifestFetcher. + * @param {string | undefined} url - Default Manifest url, will be used when + * no URL is provided to the `fetch` function. + * `undefined` if unknown or if a Manifest should be retrieved through other + * means than an HTTP request. + * @param {Object} pipelines - Transport pipelines used to perform the + * Manifest loading and parsing operations. + * @param {Object} settings - Configure the `ManifestFetcher`. */ constructor( url : string | undefined, pipelines : ITransportPipelines, - backoffOptions : IManifestFetcherBackoffOptions + settings : IManifestFetcherSettings ) { this._manifestUrl = url; this._pipelines = pipelines.manifest; - this._backoffOptions = getManifestBackoffOptions(backoffOptions); + this._settings = settings; } /** - * (re-)Load the Manifest without yet parsing it. + * (re-)Load the 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 Manifest will be requested. - * If not set, the regular Manifest url - defined on the - * `ManifestFetcher` instanciation - will be used instead. + * If not set, the regular Manifest url - defined on the `ManifestFetcher` + * instanciation - will be used instead. + * * @param {string} [url] * @returns {Observable} */ public fetch(url? : string) : Observable { - const requestUrl = url ?? this._manifestUrl; - - // TODO Remove the resolver completely in the next major version - const resolver : IManifestResolverFunction = - this._pipelines.resolver ?? - observableOf; - - const loader : IManifestLoaderFunction = this._pipelines.loader; - - return tryCatch$(resolver, { url: requestUrl }).pipe( - catchError((error : Error) : Observable => { - throw errorSelector(error); - }), - mergeMap((loaderArgument : IManifestLoaderArguments) => { - const loader$ = tryCatch$(loader, loaderArgument); - return tryRequestObservableWithBackoff(loader$, this._backoffOptions).pipe( - catchError((error : unknown) : Observable => { - throw errorSelector(error); - }), - map((evt) => { - return evt.type === "retry" ? - ({ type: "warning" as const, value: errorSelector(evt.value) }) : - ({ - type: "response" as const, - parse: (parserOptions : IManifestFetcherParserOptions) => { - return this._parseLoadedManifest(evt.value.value, parserOptions); - }, - }); - })); - })); + IManifestFetcherWarningEvent> + { + return new Observable((obs) => { + const pipelines = this._pipelines; + const requestUrl = url ?? this._manifestUrl; + + /** `true` if the loading pipeline is already completely executed. */ + let hasFinishedLoading = false; + + /** Allows to cancel the loading operation. */ + const canceller = new TaskCanceller(); + + const backoffSettings = this._getBackoffSetting((err) => { + obs.next({ type: "warning", value: errorSelector(err) }); + }); + + const loadingPromise = pipelines.resolveManifestUrl === undefined ? + callLoaderWithRetries(requestUrl) : + callResolverWithRetries(requestUrl).then(callLoaderWithRetries); + + loadingPromise + .then(response => { + hasFinishedLoading = true; + obs.next({ + type: "response", + parse: (parserOptions : IManifestFetcherParserOptions) => { + return this._parseLoadedManifest(response, parserOptions); + }, + }); + obs.complete(); + }) + .catch((err : unknown) => { + if (canceller.isUsed) { + // Cancellation has already been handled by RxJS + return; + } + hasFinishedLoading = true; + obs.error(errorSelector(err)); + }); + + return () => { + if (!hasFinishedLoading) { + canceller.cancel(); + } + }; + + /** + * Call the resolver part of the pipeline, retrying if it fails according + * to the current settings. + * Returns the Promise of the last attempt. + * /!\ This pipeline should have a `resolveManifestUrl` function defined. + * @param {string | undefined} resolverUrl + * @returns {Promise} + */ + function callResolverWithRetries(resolverUrl : string | undefined) { + const { resolveManifestUrl } = pipelines; + assert(resolveManifestUrl !== undefined); + const callResolver = () => resolveManifestUrl(resolverUrl, canceller.signal); + return tryRequestPromiseWithBackoff(callResolver, + backoffSettings, + canceller.signal); + } + + /** + * Call the loader part of the pipeline, retrying if it fails according + * to the current settings. + * Returns the Promise of the last attempt. + * @param {string | undefined} resolverUrl + * @returns {Promise} + */ + function callLoaderWithRetries(manifestUrl : string | undefined) { + const { loadManifest } = pipelines; + const callLoader = () => loadManifest(manifestUrl, canceller.signal); + return tryRequestPromiseWithBackoff(callLoader, + backoffSettings, + canceller.signal); + } + }); } /** @@ -231,59 +280,157 @@ export default class ManifestFetcher { * @returns {Observable} */ private _parseLoadedManifest( - loaded : ILoaderDataLoadedValue, + loaded : IRequestedData, parserOptions : IManifestFetcherParserOptions ) : Observable { - const { sendingTime, receivedTime } = loaded; - const parsingTimeStart = performance.now(); - - // Prepare RequestScheduler - // TODO Remove the need of a subject - type IRequestSchedulerData = ILoaderDataLoadedValue; - const schedulerWarnings$ = new Subject(); - const scheduleRequest = - createRequestScheduler(this._backoffOptions, - schedulerWarnings$); - - return observableMerge( - schedulerWarnings$ - .pipe(map(err => ({ type: "warning" as const, value: err }))), - this._pipelines.parser({ response: loaded, - url: this._manifestUrl, - externalClockOffset: parserOptions.externalClockOffset, - previousManifest: parserOptions.previousManifest, - scheduleRequest, - unsafeMode: parserOptions.unsafeMode, - }).pipe( - catchError((error: unknown) => { - throw formatError(error, { - defaultCode: "PIPELINE_PARSE_ERROR", - defaultReason: "Unknown error when parsing the Manifest", - }); - }), - map((parsingEvt) => { - if (parsingEvt.type === "warning") { - const formatted = formatError(parsingEvt.value, { - defaultCode: "PIPELINE_PARSE_ERROR", - defaultReason: "Unknown error when parsing the Manifest", + IManifestFetcherParsedResult> + { + return new Observable(obs => { + const parsingTimeStart = performance.now(); + const canceller = new TaskCanceller(); + const { sendingTime, receivedTime } = loaded; + const backoffSettings = this._getBackoffSetting((err) => { + obs.next({ type: "warning", value: errorSelector(err) }); + }); + + const opts = { externalClockOffset: parserOptions.externalClockOffset, + unsafeMode: parserOptions.unsafeMode, + previousManifest: parserOptions.previousManifest, + originalUrl: this._manifestUrl }; + try { + const res = this._pipelines.parseManifest(loaded, + opts, + onWarnings, + canceller.signal, + scheduleRequest); + if (!isPromise(res)) { + emitManifestAndComplete(res.manifest); + } else { + res + .then(({ manifest }) => emitManifestAndComplete(manifest)) + .catch((err) => { + if (canceller.isUsed) { + // Cancellation is already handled by RxJS + return; + } + emitError(err, true); }); - return { type: "warning" as const, - value: formatted }; + } + } catch (err) { + if (canceller.isUsed) { + // Cancellation is already handled by RxJS + return undefined; + } + emitError(err, true); + } + + return () => { + canceller.cancel(); + }; + + /** + * Perform a request with the same retry mechanisms and error handling + * than for a Manifest loader. + * @param {Function} performRequest + * @returns {Function} + */ + async function scheduleRequest( + performRequest : () => Promise + ) : Promise { + try { + const data = await tryRequestPromiseWithBackoff(performRequest, + backoffSettings, + canceller.signal); + return data; + } catch (err) { + throw errorSelector(err); + } + } + + /** + * Handle minor errors encountered by a Manifest parser. + * @param {Array.} warnings + */ + function onWarnings(warnings : Error[]) : void { + for (const warning of warnings) { + if (canceller.isUsed) { + return; } + emitError(warning, false); + } + } - // 2 - send response - const parsingTime = performance.now() - parsingTimeStart; - log.info(`MF: Manifest parsed in ${parsingTime}ms`); + /** + * Emit a formatted "parsed" event through `obs`. + * To call once the Manifest has been parsed. + * @param {Object} manifest + */ + function emitManifestAndComplete(manifest : Manifest) : void { + onWarnings(manifest.parsingErrors); + const parsingTime = performance.now() - parsingTimeStart; + log.info(`MF: Manifest parsed in ${parsingTime}ms`); - return { type: "parsed" as const, - manifest: parsingEvt.value.manifest, + obs.next({ type: "parsed" as const, + manifest, sendingTime, receivedTime, - parsingTime }; + parsingTime }); + obs.complete(); + } - }), - finalize(() => { schedulerWarnings$.complete(); }) - )); + /** + * 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 + * @param {boolean} isFatal + */ + function emitError(err : unknown, isFatal : boolean) : void { + const formattedError = formatError(err, { + defaultCode: "PIPELINE_PARSE_ERROR", + defaultReason: "Unknown error when parsing the Manifest", + }); + if (isFatal) { + obs.error(formattedError); + } else { + obs.next({ type: "warning" as const, + value: formattedError }); + } + } + }); } + + /** + * 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 { lowLatencyMode, + maxRetryRegular : ogRegular, + maxRetryOffline : ogOffline } = this._settings; + const baseDelay = lowLatencyMode ? INITIAL_BACKOFF_DELAY_BASE.LOW_LATENCY : + INITIAL_BACKOFF_DELAY_BASE.REGULAR; + const maxDelay = lowLatencyMode ? MAX_BACKOFF_DELAY_BASE.LOW_LATENCY : + MAX_BACKOFF_DELAY_BASE.REGULAR; + const maxRetryRegular = ogRegular ?? DEFAULT_MAX_MANIFEST_REQUEST_RETRY; + const maxRetryOffline = ogOffline ?? DEFAULT_MAX_REQUESTS_RETRY_ON_OFFLINE; + return { onRetry, + baseDelay, + maxDelay, + maxRetryRegular, + maxRetryOffline }; + } +} + +/** + * Returns `true` when the returned value seems to be a Promise instance, as + * created by the RxPlayer. + * @param {*} val + * @returns {boolean} + */ +function isPromise(val : T | Promise) : val is Promise { + return val instanceof PPromise || + val instanceof Promise; } diff --git a/src/core/fetchers/segment/create_segment_loader.ts b/src/core/fetchers/segment/create_segment_loader.ts deleted file mode 100644 index 3ce79d00b8..0000000000 --- a/src/core/fetchers/segment/create_segment_loader.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * 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 { - concat as observableConcat, - EMPTY, - Observable, - of as observableOf, -} from "rxjs"; -import { - catchError, - map, - mergeMap, -} from "rxjs/operators"; -import { ICustomError } from "../../../errors"; -import Manifest, { - Adaptation, - ISegment, - Period, - Representation, -} from "../../../manifest"; -import { - ILoaderDataLoadedValue, - ILoaderProgressEvent, - ISegmentLoaderArguments, - ISegmentLoaderEvent as ITransportSegmentLoaderEvent, -} from "../../../transports"; -import assertUnreachable from "../../../utils/assert_unreachable"; -import castToObservable from "../../../utils/cast_to_observable"; -import objectAssign from "../../../utils/object_assign"; -import tryCatch from "../../../utils/rx-try_catch"; -import { IABRMetricsEvent } from "../../abr"; -import errorSelector from "../utils/error_selector"; -import tryURLsWithBackoff, { - IBackoffOptions, -} from "../utils/try_urls_with_backoff"; - -/** Data comes from a local JS cache maintained here, no request was done. */ -interface ISegmentLoaderCachedSegmentEvent { type : "cache"; - value : ILoaderDataLoadedValue; } - -/** An Error happened while loading (usually a request error). */ -export interface ISegmentLoaderWarning { type : "warning"; - value : ICustomError; } - -/** The request begins to be done. */ -export interface ISegmentLoaderRequest { type : "request"; - value : ISegmentLoaderArguments; } - -/** The whole segment's data (not only a chunk) is available. */ -export interface ISegmentLoaderData { type : "data"; - value : { responseData : T }; } - -/** - * A chunk of the data is available. - * You will receive every chunk through such events until a - * ISegmentLoaderChunkComplete event is received. - */ -export interface ISegmentLoaderChunk { type : "chunk"; - value : { responseData : ArrayBuffer | - Uint8Array; }; } - -/** The data has been entirely sent through "chunk" events. */ -export interface ISegmentLoaderChunkComplete { type : "chunk-complete"; - value : null; } - -/** - * Every events the segment loader emits. - * Type parameters: T: Argument given to the loader - * U: ResponseType of the request - */ -export type ISegmentLoaderEvent = ISegmentLoaderData | - ISegmentLoaderRequest | - ILoaderProgressEvent | - ISegmentLoaderWarning | - ISegmentLoaderChunk | - ISegmentLoaderChunkComplete | - IABRMetricsEvent; - -/** Cache implementation to avoid re-requesting segment */ -export interface ISegmentLoaderCache { - /** Add a segment to the cache. */ - add : (obj : ISegmentLoaderContent, arg : ILoaderDataLoadedValue) => void; - /** Retrieve a segment from the cache */ - get : (obj : ISegmentLoaderContent) => ILoaderDataLoadedValue | null; -} - -/** Abstraction to load a segment in the current transport protocol. */ -export type ISegmentPipelineLoader = - (x : ISegmentLoaderArguments) => Observable< ITransportSegmentLoaderEvent >; - -/** Content used by the segment loader as a context to load a new segment. */ -export interface ISegmentLoaderContent { manifest : Manifest; - period : Period; - adaptation : Adaptation; - representation : Representation; - segment : ISegment; } - -/** - * Returns a function allowing to load any wanted segment. - * - * The function returned takes in argument information about the wanted segment - * and returns an Observable which will emit various events related to the - * segment request (see ISegmentLoaderEvent). - * - * This observable will throw if, following the options given, the request and - * possible retry all failed. - * - * This observable will complete after emitting all the segment's data. - * - * Type parameters: - * - T: type of the data emitted - * - * @param {Function} loader - * @param {Object | undefined} cache - * @param {Object} options - * @returns {Function} - */ -export default function createSegmentLoader( - loader : ISegmentPipelineLoader, - cache : ISegmentLoaderCache | undefined, - backoffOptions : IBackoffOptions -) : (x : ISegmentLoaderContent) => Observable> { - /** - * Try to retrieve the segment from the cache and if not found call the - * pipeline's loader (with possible retries) to load it. - * @param {Object} loaderArgument - Context for the wanted segment. - * @returns {Observable} - */ - function loadData( - wantedContent : ISegmentLoaderContent - ) : Observable< ITransportSegmentLoaderEvent | - ISegmentLoaderRequest | - ISegmentLoaderWarning | - ISegmentLoaderCachedSegmentEvent> - { - - /** - * Call the Pipeline's loader with an exponential Backoff. - * @returns {Observable} - */ - function startLoaderWithBackoff( - ) : Observable< ITransportSegmentLoaderEvent | - ISegmentLoaderRequest | - ISegmentLoaderWarning > - { - const request$ = (url : string | null) => { - const loaderArgument = objectAssign({ url }, wantedContent); - return observableConcat( - observableOf({ type: "request" as const, value: loaderArgument }), - tryCatch(loader, loaderArgument)); - }; - return tryURLsWithBackoff(wantedContent.segment.mediaURLs ?? [null], - request$, - backoffOptions).pipe( - catchError((error : unknown) : Observable => { - throw errorSelector(error); - }), - - map((evt) : ITransportSegmentLoaderEvent | - ISegmentLoaderWarning | - ISegmentLoaderRequest => { - if (evt.type === "retry") { - return { type: "warning" as const, - value: errorSelector(evt.value) }; - } else if (evt.value.type === "request") { - return evt.value; - } - - const response = evt.value; - if (response.type === "data-loaded" && cache != null) { - cache.add(wantedContent, response.value); - } - return evt.value; - })); - } - - const dataFromCache = cache != null ? cache.get(wantedContent) : - null; - - if (dataFromCache != null) { - return castToObservable(dataFromCache).pipe( - map(response => ({ type: "cache" as const, value: response })), - catchError(startLoaderWithBackoff) - ); - } - - return startLoaderWithBackoff(); - } - - /** - * Load the corresponding segment. - * @param {Object} content - * @returns {Observable} - */ - return function loadSegment( - content : ISegmentLoaderContent - ) : Observable> { - return loadData(content).pipe( - mergeMap((arg) : Observable> => { - let metrics$ : Observable; - if ((arg.type === "data-chunk-complete" || arg.type === "data-loaded") && - arg.value.size !== undefined && arg.value.duration !== undefined) - { - metrics$ = observableOf({ type: "metrics", - value: { size: arg.value.size, - duration: arg.value.duration, - content } }); - } else { - metrics$ = EMPTY; - } - - switch (arg.type) { - case "warning": - case "request": - case "progress": - return observableOf(arg); - case "cache": - case "data-created": - case "data-loaded": - return observableConcat(observableOf({ type: "data" as const, - value: arg.value }), - metrics$); - - case "data-chunk": - return observableOf({ type: "chunk", value: arg.value }); - case "data-chunk-complete": - return observableConcat(observableOf({ type: "chunk-complete" as const, - value: null }), - metrics$); - - default: - assertUnreachable(arg); - } - })); - }; -} diff --git a/src/core/fetchers/segment/get_segment_backoff_options.ts b/src/core/fetchers/segment/get_segment_backoff_options.ts deleted file mode 100644 index a5971eb9c5..0000000000 --- a/src/core/fetchers/segment/get_segment_backoff_options.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * 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 { IBackoffOptions } from "../utils/try_urls_with_backoff"; - -const { DEFAULT_MAX_REQUESTS_RETRY_ON_ERROR, - DEFAULT_MAX_REQUESTS_RETRY_ON_OFFLINE, - INITIAL_BACKOFF_DELAY_BASE, - MAX_BACKOFF_DELAY_BASE } = config; - -/** - * @param {string} bufferType - * @param {Object} - * @returns {Object} - */ -export default function getSegmentBackoffOptions( - bufferType : string, - { maxRetryRegular, - maxRetryOffline, - lowLatencyMode } : { maxRetryRegular? : number; - maxRetryOffline? : number; - lowLatencyMode : boolean; } -) : IBackoffOptions { - return { maxRetryRegular: bufferType === "image" ? 0 : - maxRetryRegular ?? DEFAULT_MAX_REQUESTS_RETRY_ON_ERROR, - maxRetryOffline: maxRetryOffline ?? DEFAULT_MAX_REQUESTS_RETRY_ON_OFFLINE, - baseDelay: lowLatencyMode ? INITIAL_BACKOFF_DELAY_BASE.LOW_LATENCY : - INITIAL_BACKOFF_DELAY_BASE.REGULAR, - maxDelay: lowLatencyMode ? MAX_BACKOFF_DELAY_BASE.LOW_LATENCY : - MAX_BACKOFF_DELAY_BASE.REGULAR }; -} diff --git a/src/core/fetchers/segment/prioritized_segment_fetcher.ts b/src/core/fetchers/segment/prioritized_segment_fetcher.ts index c77d6e2640..41c9cc8372 100644 --- a/src/core/fetchers/segment/prioritized_segment_fetcher.ts +++ b/src/core/fetchers/segment/prioritized_segment_fetcher.ts @@ -17,11 +17,11 @@ import { Observable } from "rxjs"; import { map } from "rxjs/operators"; import log from "../../../log"; -import { ISegmentLoaderContent } from "./create_segment_loader"; import ObservablePrioritizer, { ITaskEvent } from "./prioritizer"; import { ISegmentFetcher, ISegmentFetcherEvent, + ISegmentLoaderContent, } from "./segment_fetcher"; /** diff --git a/src/core/fetchers/segment/segment_fetcher.ts b/src/core/fetchers/segment/segment_fetcher.ts index 1a1f67d8d7..9b096621af 100644 --- a/src/core/fetchers/segment/segment_fetcher.ts +++ b/src/core/fetchers/segment/segment_fetcher.ts @@ -15,29 +15,30 @@ */ import { - concat as observableConcat, Observable, - of as observableOf, Subject, } from "rxjs"; +import config from "../../../config"; +import { formatError, ICustomError } from "../../../errors"; +import Manifest, { + Adaptation, + ISegment, + Period, + Representation, +} from "../../../manifest"; import { - filter, - finalize, - mergeMap, - share, - tap, -} from "rxjs/operators"; -import { formatError } from "../../../errors"; -import { ISegment } from "../../../manifest"; -import { + ISegmentLoadingProgressInformation, ISegmentParserParsedInitSegment, ISegmentParserParsedSegment, ISegmentPipeline, } from "../../../transports"; import arrayIncludes from "../../../utils/array_includes"; -import assertUnreachable from "../../../utils/assert_unreachable"; import idGenerator from "../../../utils/id_generator"; import InitializationSegmentCache from "../../../utils/initialization_segment_cache"; +import objectAssign from "../../../utils/object_assign"; +import TaskCanceller, { + CancellationSignal, +} from "../../../utils/task_canceller"; import { IABRMetricsEvent, IABRRequestBeginEvent, @@ -45,56 +46,13 @@ import { IABRRequestProgressEvent, } from "../../abr"; import { IBufferType } from "../../segment_buffers"; -import { IBackoffOptions } from "../utils/try_urls_with_backoff"; -import createSegmentLoader, { - ISegmentLoaderChunk, - ISegmentLoaderChunkComplete, - ISegmentLoaderContent, - ISegmentLoaderData, - ISegmentLoaderWarning, -} from "./create_segment_loader"; - -/** - * Event sent when the segment request needs to be renewed (e.g. due to an HTTP - * error). - */ -export type ISegmentFetcherWarning = ISegmentLoaderWarning; - -/** - * Event sent when a new "chunk" of the segment is available. - * A segment can contain n chunk(s) for n >= 0. - */ -export interface ISegmentFetcherChunkEvent { - type : "chunk"; - /** - * Parse the downloaded chunk. - * - * Take in argument the timescale value that might have been obtained by - * parsing an initialization segment from the same Representation. - * Can be left to `undefined` if unknown or inexistant, segment parsers should - * be resilient and still work without that information. - * - * @param {number} initTimescale - * @returns {Object} - */ - parse(initTimescale? : number) : ISegmentParserParsedInitSegment | - ISegmentParserParsedSegment; -} - -/** - * Event sent when all "chunk" of the segments have been communicated through - * `ISegmentFetcherChunkEvent` events. - */ -export interface ISegmentFetcherChunkCompleteEvent { type: "chunk-complete" } +import errorSelector from "../utils/error_selector"; +import { tryURLsWithBackoff } from "../utils/try_urls_with_backoff"; -/** Event sent by the SegmentFetcher when fetching a segment. */ -export type ISegmentFetcherEvent = - ISegmentFetcherChunkCompleteEvent | - ISegmentFetcherChunkEvent | - ISegmentFetcherWarning; - -export type ISegmentFetcher = (content : ISegmentLoaderContent) => - Observable>; +const { DEFAULT_MAX_REQUESTS_RETRY_ON_ERROR, + DEFAULT_MAX_REQUESTS_RETRY_ON_OFFLINE, + INITIAL_BACKOFF_DELAY_BASE, + MAX_BACKOFF_DELAY_BASE } = config; const generateRequestID = idGenerator(); @@ -108,137 +66,290 @@ const generateRequestID = idGenerator(); */ export default function createSegmentFetcher< LoadedFormat, - TSegmentDataType + TSegmentDataType, >( bufferType : IBufferType, - segmentPipeline : ISegmentPipeline, + pipeline : ISegmentPipeline, requests$ : Subject, - options : IBackoffOptions + options : ISegmentFetcherOptions ) : ISegmentFetcher { + + /** + * Cache audio and video initialization segments. + * This allows to avoid doing too many requests for what are usually very + * small files. + */ const cache = arrayIncludes(["audio", "video"], bufferType) ? - new InitializationSegmentCache() : + new InitializationSegmentCache() : undefined; - const segmentLoader = createSegmentLoader(segmentPipeline.loader, cache, options); - const segmentParser = segmentPipeline.parser; + + const { loadSegment, parseSegment } = pipeline; /** - * Process the segmentLoader observable to adapt it to the the rest of the - * code: - * - use the requests subject for network requests and their progress - * - use the warning$ subject for retries' error messages - * - only emit the data + * Fetch a specific segment. + * + * This function returns an Observable which will fetch the segment on + * subscription. + * This Observable will emit various events during that request lifecycle and + * throw if the segment request(s) (including potential retries) fail. + * + * The Observable will automatically complete once no events are left to be + * sent. * @param {Object} content * @returns {Observable} */ return function fetchSegment( content : ISegmentLoaderContent ) : Observable> { - const id = generateRequestID(); - let requestBeginSent = false; - return segmentLoader(content).pipe( - tap((arg) => { - switch (arg.type) { - case "metrics": { - requests$.next(arg); - break; - } + const { segment } = content; + return new Observable((obs) => { + // Retrieve from cache if it exists + const cached = cache !== undefined ? cache.get(content) : + null; + if (cached !== null) { + obs.next({ type: "chunk" as const, + parse: generateParserFunction(cached, false) }); + obs.next({ type: "chunk-complete" as const }); + obs.complete(); + return undefined; + } - case "request": { - const { value } = arg; + const id = generateRequestID(); + requests$.next({ type: "requestBegin", + value: { duration: segment.duration, + time: segment.time, + requestTimestamp: performance.now(), + id } }); - // format it for ABR Handling - const segment : ISegment|undefined = value.segment; - if (segment === undefined) { - return; - } - requestBeginSent = true; - requests$.next({ type: "requestBegin", - value: { duration: segment.duration, - time: segment.time, - requestTimestamp: performance.now(), + const canceller = new TaskCanceller(); + let hasRequestEnded = false; + + const loaderCallbacks = { + /** + * Callback called when the segment loader has progress information on + * the request. + * @param {Object} info + */ + onProgress(info : ISegmentLoadingProgressInformation) : void { + if (info.totalSize !== undefined && info.size < info.totalSize) { + requests$.next({ type: "progress", + value: { duration: info.duration, + size: info.size, + totalSize: info.totalSize, + timestamp: performance.now(), id } }); - break; } + }, + + /** + * Callback called when the segment is communicated by the loader + * through decodable sub-segment(s) called chunk(s), with a chunk in + * argument. + * @param {*} chunkData + */ + onNewChunk(chunkData : LoadedFormat) : void { + obs.next({ type: "chunk" as const, + parse: generateParserFunction(chunkData, true) }); + }, + }; - case "progress": { - const { value } = arg; - if (value.totalSize != null && value.size < value.totalSize) { - requests$.next({ type: "progress", - value: { duration: value.duration, - size: value.size, - totalSize: value.totalSize, - timestamp: performance.now(), - id } }); + + tryURLsWithBackoff(segment.mediaURLs ?? [null], + callLoaderWithUrl, + objectAssign({ onRetry }, options), + canceller.signal) + .then((res) => { + if (res.resultType === "segment-loaded") { + const loadedData = res.resultData.responseData; + if (cache !== undefined) { + cache.add(content, res.resultData.responseData); } - break; + obs.next({ type: "chunk" as const, + parse: generateParserFunction(loadedData, false) }); + } else if (res.resultType === "segment-created") { + obs.next({ type: "chunk" as const, + parse: generateParserFunction(res.resultData, false) }); } - } - }), - finalize(() => { - if (requestBeginSent) { + hasRequestEnded = true; + obs.next({ type: "chunk-complete" as const }); + + if ((res.resultType === "segment-loaded" || + res.resultType === "chunk-complete") && + res.resultData.size !== undefined && + res.resultData.duration !== undefined) + { + requests$.next({ type: "metrics", + value: { size: res.resultData.size, + duration: res.resultData.duration, + content } }); + } + + if (!canceller.isUsed) { + // The current Observable could have been canceled as a result of one + // of the previous `next` calls. In that case, we don't want to send + // a "requestEnd" again as it has already been sent on cancellation. + // + // Note that we only perform this check for `"requestEnd"` on + // purpose. Observable's events should have been ignored by RxJS if + // the Observable has already been canceled and we don't care if + // `"metrics"` is sent there. + requests$.next({ type: "requestEnd", value: { id } }); + } + obs.complete(); + }) + .catch((err) => { + hasRequestEnded = true; + obs.error(errorSelector(err)); + }); + + return () => { + if (!hasRequestEnded) { + canceller.cancel(); requests$.next({ type: "requestEnd", value: { id } }); } - }), - - filter((e) : e is ISegmentLoaderChunk | - ISegmentLoaderChunkComplete | - ISegmentLoaderData | - ISegmentFetcherWarning => { - switch (e.type) { - case "warning": - case "chunk": - case "chunk-complete": - case "data": - return true; - case "progress": - case "metrics": - case "request": - return false; - default: - assertUnreachable(e); - } - }), - mergeMap((evt) => { - if (evt.type === "warning") { - return observableOf(evt); - } - if (evt.type === "chunk-complete") { - return observableOf({ type: "chunk-complete" as const }); - } + }; - const isChunked = evt.type === "chunk"; - const data = { - type: "chunk" as const, - /** - * Parse the loaded data. - * @param {Object} [initTimescale] - * @returns {Observable} - */ - parse(initTimescale? : number) : - ISegmentParserParsedInitSegment | - ISegmentParserParsedSegment - { - const response = { data: evt.value.responseData, isChunked }; - try { - return segmentParser({ response, initTimescale, content }); - } catch (error : unknown) { - throw formatError(error, { defaultCode: "PIPELINE_PARSE_ERROR", - defaultReason: "Unknown parsing error" }); - } - }, + /** + * Call a segment loader for the given URL with the right arguments. + * @param {string|null} url + * @param {Object} cancellationSignal + * @returns {Promise} + */ + function callLoaderWithUrl( + url : string | null, + cancellationSignal: CancellationSignal + ) { + return loadSegment(url, content, cancellationSignal, loaderCallbacks); + } + + /** + * Generate function allowing to parse a loaded segment. + * @param {*} data + * @param {Boolean} isChunked + * @returns {Function} + */ + function generateParserFunction(data : LoadedFormat, isChunked : boolean) { + return function parse(initTimescale? : number) : + ISegmentParserParsedInitSegment | + ISegmentParserParsedSegment + { + const loaded = { data, isChunked }; + + try { + return parseSegment(loaded, content, initTimescale); + } catch (error) { + throw formatError(error, { defaultCode: "PIPELINE_PARSE_ERROR", + defaultReason: "Unknown parsing error" }); + } }; + } - if (isChunked) { - return observableOf(data); - } - return observableConcat(observableOf(data), - observableOf({ type: "chunk-complete" as const })); - }), - share() // avoid multiple side effects if multiple subs - ); + /** + * Function called when the function request is retried. + * @param {*} err + */ + function onRetry(err: unknown) : void { + obs.next({ type: "warning" as const, + value: errorSelector(err) }); + } + }); }; } + +export type ISegmentFetcher = (content : ISegmentLoaderContent) => + Observable>; + +/** Event sent by the SegmentFetcher when fetching a segment. */ +export type ISegmentFetcherEvent = + ISegmentFetcherChunkCompleteEvent | + ISegmentFetcherChunkEvent | + ISegmentFetcherWarning; + + +/** + * Event sent when a new "chunk" of the segment is available. + * A segment can contain n chunk(s) for n >= 0. + */ +export interface ISegmentFetcherChunkEvent { + type : "chunk"; + /** Parse the downloaded chunk. */ + /** + * Parse the downloaded chunk. + * + * Take in argument the timescale value that might have been obtained by + * parsing an initialization segment from the same Representation. + * Can be left to `undefined` if unknown or inexistant, segment parsers should + * be resilient and still work without that information. + * + * @param {number} initTimescale + * @returns {Object} + */ + parse(initTimescale? : number) : ISegmentParserParsedInitSegment | + ISegmentParserParsedSegment; +} + +/** + * Event sent when all "chunk" of the segments have been communicated through + * `ISegmentFetcherChunkEvent` events. + */ +export interface ISegmentFetcherChunkCompleteEvent { type: "chunk-complete" } + +/** Content used by the segment loader as a context to load a new segment. */ +export interface ISegmentLoaderContent { manifest : Manifest; + period : Period; + adaptation : Adaptation; + representation : Representation; + segment : ISegment; } + +/** An Error happened while loading (usually a request error). */ +export interface ISegmentFetcherWarning { type : "warning"; + value : ICustomError; } + +export interface ISegmentFetcherOptions { + /** + * Initial delay to wait if a request fails before making a new request, in + * milliseconds. + */ + baseDelay : number; + /** + * Maximum delay to wait if a request fails before making a new request, in + * milliseconds. + */ + maxDelay : number; + /** + * Maximum number of retries to perform on "regular" errors (e.g. due to HTTP + * status, integrity errors, timeouts...). + */ + maxRetryRegular : number; + /** + * Maximum number of retries to perform when it appears that the user is + * currently offline. + */ + maxRetryOffline : number; +} + +/** + * @param {string} bufferType + * @param {Object} + * @returns {Object} + */ +export function getSegmentFetcherOptions( + bufferType : string, + { maxRetryRegular, + maxRetryOffline, + lowLatencyMode } : { maxRetryRegular? : number; + maxRetryOffline? : number; + lowLatencyMode : boolean; } +) : ISegmentFetcherOptions { + return { maxRetryRegular: bufferType === "image" ? 0 : + maxRetryRegular ?? DEFAULT_MAX_REQUESTS_RETRY_ON_ERROR, + maxRetryOffline: maxRetryOffline ?? DEFAULT_MAX_REQUESTS_RETRY_ON_OFFLINE, + baseDelay: lowLatencyMode ? INITIAL_BACKOFF_DELAY_BASE.LOW_LATENCY : + INITIAL_BACKOFF_DELAY_BASE.REGULAR, + maxDelay: lowLatencyMode ? MAX_BACKOFF_DELAY_BASE.LOW_LATENCY : + MAX_BACKOFF_DELAY_BASE.REGULAR }; +} diff --git a/src/core/fetchers/segment/segment_fetcher_creator.ts b/src/core/fetchers/segment/segment_fetcher_creator.ts index 34f6f01ab1..92cba08bce 100644 --- a/src/core/fetchers/segment/segment_fetcher_creator.ts +++ b/src/core/fetchers/segment/segment_fetcher_creator.ts @@ -24,12 +24,12 @@ import { IABRRequestProgressEvent, } from "../../abr"; import { IBufferType } from "../../segment_buffers"; -import getSegmentBackoffOptions from "./get_segment_backoff_options"; import applyPrioritizerToSegmentFetcher, { IPrioritizedSegmentFetcher, } from "./prioritized_segment_fetcher"; import ObservablePrioritizer from "./prioritizer"; import createSegmentFetcher, { + getSegmentFetcherOptions, ISegmentFetcherEvent, } from "./segment_fetcher"; @@ -127,7 +127,7 @@ export default class SegmentFetcherCreator { IABRRequestEndEvent | IABRMetricsEvent> ) : IPrioritizedSegmentFetcher { - const backoffOptions = getSegmentBackoffOptions(bufferType, this._backoffOptions); + const backoffOptions = getSegmentFetcherOptions(bufferType, this._backoffOptions); const pipelines = this._transport[bufferType]; // Types are very complicated here as they are per-type of buffer. diff --git a/src/core/fetchers/utils/create_request_scheduler.ts b/src/core/fetchers/utils/create_request_scheduler.ts deleted file mode 100644 index 7dd23755c1..0000000000 --- a/src/core/fetchers/utils/create_request_scheduler.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 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 { - Observable, - Subject, -} from "rxjs"; -import { catchError } from "rxjs/operators"; -import { ICustomError } from "../../../errors"; -import filterMap from "../../../utils/filter_map"; -import tryCatch from "../../../utils/rx-try_catch"; -import errorSelector from "./error_selector"; -import { - IBackoffEvent, - IBackoffOptions, - tryRequestObservableWithBackoff, -} from "./try_urls_with_backoff"; - -export default function createRequestScheduler( - backoffOptions : IBackoffOptions, - warning$ : Subject -) : ((fn: () => Observable) => Observable) { - - /** - * Allow the parser to schedule a new request. - * @param {Function} request - Function performing the request. - * @returns {Function} - */ - return function scheduleRequest(request : () => Observable) : Observable { - return tryRequestObservableWithBackoff(tryCatch(request, undefined), - backoffOptions).pipe( - filterMap, T, null>((evt) => { - if (evt.type === "retry") { - warning$.next(errorSelector(evt.value)); - return null; - } - return evt.value; - }, null), - catchError((error : unknown) : Observable => { - throw errorSelector(error); - })); - }; -} diff --git a/src/core/fetchers/utils/try_urls_with_backoff.ts b/src/core/fetchers/utils/try_urls_with_backoff.ts index 6a6e022651..071d9c73f5 100644 --- a/src/core/fetchers/utils/try_urls_with_backoff.ts +++ b/src/core/fetchers/utils/try_urls_with_backoff.ts @@ -14,17 +14,7 @@ * limitations under the License. */ -import { - EMPTY, - Observable, - timer as observableTimer, -} from "rxjs"; -import { - catchError, - map, - mergeMap, - startWith, -} from "rxjs/operators"; +import PPromise from "pinkie"; import { isOffline } from "../../../compat"; import { CustomLoaderError, @@ -33,7 +23,11 @@ import { RequestError, } from "../../../errors"; import log from "../../../log"; +import cancellableSleep from "../../../utils/cancellable_sleep"; import getFuzzedDelay from "../../../utils/get_fuzzed_delay"; +import TaskCanceller, { + CancellationSignal, +} from "../../../utils/task_canceller"; /** * Called on a loader error. @@ -92,27 +86,35 @@ function isOfflineRequestError(error : unknown) : boolean { return false; // under doubt, return false } -export interface IBackoffOptions { baseDelay : number; - maxDelay : number; - maxRetryRegular : number; - maxRetryOffline : number; } - -export interface IBackoffRetry { - type : "retry"; - value : unknown; // The error that made us retry -} - -export interface IBackoffResponse { - type : "response"; - value : T; +/** Settings to give to the backoff functions to configure their behavior. */ +export interface IBackoffSettings { + /** + * Initial delay to wait if a request fails before making a new request, in + * milliseconds. + */ + baseDelay : number; + /** + * Maximum delay to wait if a request fails before making a new request, in + * milliseconds. + */ + maxDelay : number; + /** + * Maximum number of retries to perform on "regular" errors (e.g. due to HTTP + * status, integrity errors, timeouts...). + */ + maxRetryRegular : number; + /** + * Maximum number of retries to perform when it appears that the user is + * currently offline. + */ + maxRetryOffline : number; + /** Callback called when a request is retried. */ + onRetry : (err : unknown) => void; } -export type IBackoffEvent = IBackoffRetry | - IBackoffResponse; - -enum REQUEST_ERROR_TYPES { None, - Regular, - Offline } +const enum REQUEST_ERROR_TYPES { None, + Regular, + Offline } /** * Guess the type of error obtained. @@ -129,12 +131,15 @@ function getRequestErrorType(error : unknown) : REQUEST_ERROR_TYPES { * * Here how it works: * - * 1. we give it one or multiple URLs available for the element we want to - * request, the request callback and some options + * 1. You give it one or multiple URLs available for the resource you want to + * request (from the most important URL to the least important), the + * request callback itself, and some options. * * 2. it tries to call the request callback with the first URL: - * - if it works as expected, it wrap the response in a `response` event. - * - if it fails, it emits a `retry` event and try with the next one. + * - if it works as expected, it resolves the returned Promise with that + * request's response. + * - if it fails, it calls ther `onRetry` callback given with the + * corresponding error and try with the next URL. * * 3. When all URLs have been tested (and failed), it decides - according to * the error counters, configuration and errors received - if it can retry @@ -142,34 +147,45 @@ function getRequestErrorType(error : unknown) : REQUEST_ERROR_TYPES { * - If it can, it increments the corresponding error counter, wait a * delay (based on an exponential backoff) and restart the same logic * for all retry-able URL. - * - If it can't it just throws the error. + * - If it can't it just reject the error through the returned Promise. * * Note that there are in fact two separate counters: * - one for "offline" errors * - one for other xhr errors * Both counters are resetted if the error type changes from an error to the * next. - * @param {Array.} urls + * @param {Function} performRequest * @param {Object} options - Configuration options. - * @returns {Observable} + * @param {Object} cancellationSignal + * @returns {Promise} */ -export default function tryURLsWithBackoff( +export function tryURLsWithBackoff( urls : Array, - request$ : (url : string | null) => Observable, - options : IBackoffOptions -) : Observable> { + performRequest : ( + url : string | null, + cancellationSignal : CancellationSignal + ) => Promise, + options : IBackoffSettings, + cancellationSignal : CancellationSignal +) : Promise { + if (cancellationSignal.isCancelled) { + return PPromise.reject(cancellationSignal.cancellationError); + } + const { baseDelay, maxDelay, maxRetryRegular, - maxRetryOffline } = options; + maxRetryOffline, + onRetry } = options; let retryCount = 0; let lastError = REQUEST_ERROR_TYPES.None; const urlsToTry = urls.slice(); if (urlsToTry.length === 0) { log.warn("Fetchers: no URL given to `tryURLsWithBackoff`."); - return EMPTY; + return PPromise.reject(new Error("No URL to request")); } return tryURLsRecursively(urlsToTry[0], 0); @@ -186,71 +202,87 @@ export default function tryURLsWithBackoff( * @param {number} index * @returns {Observable} */ - function tryURLsRecursively( + async function tryURLsRecursively( url : string | null, index : number - ) : Observable> { - return request$(url).pipe( - map(res => ({ type : "response" as const, value: res })), - catchError((error : unknown) => { - if (!shouldRetry(error)) { // ban this URL - if (urlsToTry.length <= 1) { // This was the last one, throw - throw error; - } + ) : Promise { + try { + const res = await performRequest(url, cancellationSignal); + return res; + } catch (error : unknown) { + if (TaskCanceller.isCancellationError(error)) { + throw error; + } + + if (!shouldRetry(error)) { + // ban this URL + if (urlsToTry.length <= 1) { // This was the last one, throw + throw error; + } - // else, remove that element from the array and go the next URL - urlsToTry.splice(index, 1); - const newIndex = index >= urlsToTry.length - 1 ? 0 : - index; - return tryURLsRecursively(urlsToTry[newIndex], newIndex) - .pipe(startWith({ type: "retry" as const, value: error })); + // else, remove that element from the array and go the next URL + urlsToTry.splice(index, 1); + const newIndex = index >= urlsToTry.length - 1 ? 0 : + index; + onRetry(error); + if (cancellationSignal.isCancelled) { + throw cancellationSignal.cancellationError; } + return tryURLsRecursively(urlsToTry[newIndex], newIndex); + } - const currentError = getRequestErrorType(error); - const maxRetry = currentError === REQUEST_ERROR_TYPES.Offline ? maxRetryOffline : - maxRetryRegular; + const currentError = getRequestErrorType(error); + const maxRetry = currentError === REQUEST_ERROR_TYPES.Offline ? maxRetryOffline : + maxRetryRegular; - if (currentError !== lastError) { - retryCount = 0; - lastError = currentError; - } + if (currentError !== lastError) { + retryCount = 0; + lastError = currentError; + } - if (index < urlsToTry.length - 1) { // there is still URLs to test - const newIndex = index + 1; - return tryURLsRecursively(urlsToTry[newIndex], newIndex) - .pipe(startWith({ type: "retry" as const, value: error })); + if (index < urlsToTry.length - 1) { // there is still URLs to test + const newIndex = index + 1; + onRetry(error); + if (cancellationSignal.isCancelled) { + throw cancellationSignal.cancellationError; } + return tryURLsRecursively(urlsToTry[newIndex], newIndex); + } - // Here, we were using the last element of the `urlsToTry` array. - // Increment counter and restart with the first URL + // Here, we were using the last element of the `urlsToTry` array. + // Increment counter and restart with the first URL - retryCount++; - if (retryCount > maxRetry) { - throw error; - } - const delay = Math.min(baseDelay * Math.pow(2, retryCount - 1), - maxDelay); - const fuzzedDelay = getFuzzedDelay(delay); - const nextURL = urlsToTry[0]; - return observableTimer(fuzzedDelay).pipe( - mergeMap(() => tryURLsRecursively(nextURL, 0)), - startWith({ type: "retry" as const, value: error })); - }) - ); + retryCount++; + if (retryCount > maxRetry) { + throw error; + } + const delay = Math.min(baseDelay * Math.pow(2, retryCount - 1), + maxDelay); + const fuzzedDelay = getFuzzedDelay(delay); + const nextURL = urlsToTry[0]; + onRetry(error); + if (cancellationSignal.isCancelled) { + throw cancellationSignal.cancellationError; + } + + await cancellableSleep(fuzzedDelay, cancellationSignal); + return tryURLsRecursively(nextURL, 0); + } } } /** * Lightweight version of the request algorithm, this time with only a simple - * Observable given. + * Promise given. * @param {Function} request$ * @param {Object} options * @returns {Observable} */ -export function tryRequestObservableWithBackoff( - request$ : Observable, - options : IBackoffOptions -) : Observable> { +export function tryRequestPromiseWithBackoff( + performRequest : () => Promise, + options : IBackoffSettings, + cancellationSignal : CancellationSignal +) : Promise { // same than for a single unknown URL - return tryURLsWithBackoff([null], () => request$, options); + return tryURLsWithBackoff([null], performRequest, options, cancellationSignal); } diff --git a/src/core/stream/representation/representation_stream.ts b/src/core/stream/representation/representation_stream.ts index 10f6f2e325..57d3c85220 100644 --- a/src/core/stream/representation/representation_stream.ts +++ b/src/core/stream/representation/representation_stream.ts @@ -201,14 +201,15 @@ export interface IRepresentationStreamOptions { * @param {Object} args * @returns {Observable} */ -export default function RepresentationStream({ +export default function RepresentationStream({ clock$, content, segmentBuffer, segmentFetcher, terminate$, options, -} : IRepresentationStreamArguments) : Observable> { +} : IRepresentationStreamArguments +) : Observable> { const { manifest, period, adaptation, representation } = content; const { bufferGoal$, drmSystemId, fastSwitchThreshold$ } = options; const bufferType = adaptation.type; @@ -218,7 +219,8 @@ export default function RepresentationStream({ * Saved initialization segment state for this representation. * `null` if the initialization segment hasn't been loaded yet. */ - let initSegmentObject : ISegmentParserParsedInitSegment | null = + let initSegmentObject : ISegmentParserParsedInitSegment | + null = initSegment === null ? { segmentType: "init", initializationData: null, protectionDataUpdate: false, @@ -238,7 +240,8 @@ export default function RepresentationStream({ * Keep track of the information about the pending segment request. * `null` if no segment request is pending in that RepresentationStream. */ - let currentSegmentRequest : ISegmentRequestObject|null = null; + let currentSegmentRequest : ISegmentRequestObject | + null = null; const status$ = observableCombineLatest([ clock$, @@ -395,9 +398,9 @@ export default function RepresentationStream({ * error). * @returns {Observable} */ - function loadSegmentsFromQueue() : Observable> { + function loadSegmentsFromQueue() : Observable> { const requestNextSegment$ = - observableDefer(() : Observable> => { + observableDefer(() : Observable> => { const currentNeededSegment = downloadQueue.shift(); if (currentNeededSegment === undefined) { nextTick(() => { reCheckNeededSegments$.next(); }); @@ -410,7 +413,7 @@ export default function RepresentationStream({ currentSegmentRequest = { segment, priority, request$ }; return request$ - .pipe(mergeMap((evt) : Observable> => { + .pipe(mergeMap((evt) : Observable> => { switch (evt.type) { case "warning": return observableOf({ type: "retry" as const, @@ -450,8 +453,8 @@ export default function RepresentationStream({ * @returns {Observable} */ function onLoaderEvent( - evt : ISegmentLoadingEvent - ) : Observable | + evt : ISegmentLoadingEvent + ) : Observable | ISegmentFetcherWarning | IEncryptionDataEncounteredEvent | IInbandEventsEvent | @@ -494,8 +497,8 @@ export default function RepresentationStream({ * @returns {Observable} */ function onParsedChunk( - evt : IParsedSegmentEvent - ) : Observable | + evt : IParsedSegmentEvent + ) : Observable | IEncryptionDataEncounteredEvent | IInbandEventsEvent | IStreamNeedsManifestRefresh | @@ -520,7 +523,6 @@ export default function RepresentationStream({ segmentData: parsed.initializationData, segmentBuffer }); return observableMerge(initEncEvt$, pushEvent$); - } else { const initSegmentData = initSegmentObject?.initializationData ?? null; const { inbandEvents, @@ -543,6 +545,7 @@ export default function RepresentationStream({ observableOf({ type: "inband-events" as const, value: inbandEvents }) : EMPTY; + return observableConcat(segmentEncryptionEvent$, manifestRefresh$, inbandEvents$, diff --git a/src/experimental/tools/VideoThumbnailLoader/create_request.ts b/src/experimental/tools/VideoThumbnailLoader/create_request.ts index f9cce62395..05165c80f0 100644 --- a/src/experimental/tools/VideoThumbnailLoader/create_request.ts +++ b/src/experimental/tools/VideoThumbnailLoader/create_request.ts @@ -1,6 +1,5 @@ import { EMPTY, - Observable, } from "rxjs"; import { catchError, @@ -8,19 +7,25 @@ import { take, } from "rxjs/operators"; import { - ISegmentLoaderContent, - ISegmentLoaderEvent, -} from "../../../core/fetchers/segment/create_segment_loader"; + ISegmentFetcher, + ISegmentFetcherChunkEvent, +} from "../../../core/fetchers/segment/segment_fetcher"; import { ISegment } from "../../../manifest"; +import { + ISegmentParserParsedInitSegment, + ISegmentParserParsedSegment, +} from "../../../transports"; import getCompleteSegmentId from "./get_complete_segment_id"; import { IContentInfos } from "./types"; const requests = new Map(); export interface ICancellableRequest { - data?: Uint8Array; + data?: ISegmentParserParsedInitSegment | + ISegmentParserParsedSegment; error?: Error; - onData?: (data: Uint8Array) => void; + onData?: (data: ISegmentParserParsedInitSegment | + ISegmentParserParsedSegment) => void; onError?: (err: Error) => void; cancel: () => void; } @@ -34,11 +39,7 @@ export interface ICancellableRequest { * @returns {Object} */ export function createRequest( - segmentLoader: ( - x : ISegmentLoaderContent - ) => Observable>, + segmentFetcher: ISegmentFetcher, contentInfos: IContentInfos, segment: ISegment ): ICancellableRequest { @@ -47,14 +48,18 @@ export function createRequest( if (lastRequest !== undefined) { return lastRequest; } - const subscription = segmentLoader({ manifest: contentInfos.manifest, - period: contentInfos.period, - adaptation: contentInfos.adaptation, - representation: contentInfos.representation, - segment }).pipe( - filter((evt): evt is { type: "data"; - value: { responseData: Uint8Array }; } => - evt.type === "data" + + const _request: ICancellableRequest = { + cancel: () => subscription.unsubscribe(), + }; + + const subscription = segmentFetcher({ manifest: contentInfos.manifest, + period: contentInfos.period, + adaptation: contentInfos.adaptation, + representation: contentInfos.representation, + segment }).pipe( + filter((evt): evt is ISegmentFetcherChunkEvent => + evt.type === "chunk" ), take(1), catchError((err: Error) => { @@ -65,16 +70,13 @@ export function createRequest( return EMPTY; }) ).subscribe((evt) => { - _request.data = evt.value.responseData; + const parsed = evt.parse(); + _request.data = parsed; if (_request.onData !== undefined) { - _request.onData(evt.value.responseData); + _request.onData(parsed); } }); - const _request: ICancellableRequest = { - cancel: () => subscription.unsubscribe(), - }; - requests.set(completeSegmentId, _request); return _request; diff --git a/src/experimental/tools/VideoThumbnailLoader/get_initialized_source_buffer.ts b/src/experimental/tools/VideoThumbnailLoader/get_initialized_source_buffer.ts index bffa8d35d7..74c36a24f9 100644 --- a/src/experimental/tools/VideoThumbnailLoader/get_initialized_source_buffer.ts +++ b/src/experimental/tools/VideoThumbnailLoader/get_initialized_source_buffer.ts @@ -24,23 +24,16 @@ import { Subject, } from "rxjs"; import { - filter, map, mapTo, mergeMap, tap, } from "rxjs/operators"; -import { - ISegmentLoaderContent, - ISegmentLoaderEvent, -} from "../../../core/fetchers/segment/create_segment_loader"; +import { ISegmentFetcher } from "../../../core/fetchers/segment/segment_fetcher"; import { AudioVideoSegmentBuffer } from "../../../core/segment_buffers/implementations"; import { ISegment } from "../../../manifest"; import prepareSourceBuffer from "./prepare_source_buffer"; -import { - IContentInfos, - IThumbnailLoaderSegmentParser, -} from "./types"; +import { IContentInfos } from "./types"; let mediaSourceSubscription: Subscription | undefined; let sourceBufferContent: IContentInfos | undefined; @@ -79,34 +72,25 @@ function hasAlreadyPushedInitData(contentInfos: IContentInfos): boolean { function loadAndPushInitData(contentInfos: IContentInfos, initSegment: ISegment, sourceBuffer: AudioVideoSegmentBuffer, - segmentParser: IThumbnailLoaderSegmentParser, - segmentLoader: ( - x : ISegmentLoaderContent - ) => Observable>) { - const inventoryInfos = { manifest: contentInfos.manifest, - period: contentInfos.period, - adaptation: contentInfos.adaptation, - representation: contentInfos.representation, - segment: initSegment }; - return segmentLoader(inventoryInfos).pipe( - filter((evt): evt is { type: "data"; value: { responseData: Uint8Array } } => - evt.type === "data"), + segmentFetcher: ISegmentFetcher) { + const segmentInfos = { manifest: contentInfos.manifest, + period: contentInfos.period, + adaptation: contentInfos.adaptation, + representation: contentInfos.representation, + segment: initSegment }; + return segmentFetcher(segmentInfos).pipe( mergeMap((evt) => { - const parsed = segmentParser({ - response: { - data: evt.value.responseData, - isChunked: false, - }, - content: inventoryInfos, - }); + if (evt.type !== "chunk") { + return EMPTY; + } + const parsed = evt.parse(); if (parsed.segmentType !== "init") { return EMPTY; } const { initializationData } = parsed; const initSegmentData = initializationData instanceof ArrayBuffer ? - new Uint8Array(initializationData) : initializationData; + new Uint8Array(initializationData) : + initializationData; return sourceBuffer .pushChunk({ data: { initSegment: initSegmentData, chunk: null, @@ -130,15 +114,7 @@ function loadAndPushInitData(contentInfos: IContentInfos, export function getInitializedSourceBuffer$( contentInfos: IContentInfos, element: HTMLVideoElement, - { segmentLoader, - segmentParser }: { - segmentLoader: ( - x : ISegmentLoaderContent - ) => Observable>; - segmentParser: IThumbnailLoaderSegmentParser; - } + segmentFetcher : ISegmentFetcher ): Observable { if (hasAlreadyPushedInitData(contentInfos)) { return sourceBuffer$; @@ -172,8 +148,7 @@ export function getInitializedSourceBuffer$( return loadAndPushInitData(contentInfos, initSegment, sourceBuffer, - segmentParser, - segmentLoader) + segmentFetcher) .pipe(mapTo(sourceBuffer)); }), tap(() => { sourceBufferContent = contentInfos; }) diff --git a/src/experimental/tools/VideoThumbnailLoader/load_segments.ts b/src/experimental/tools/VideoThumbnailLoader/load_segments.ts index 8bfaa4c86a..d69c02ba85 100644 --- a/src/experimental/tools/VideoThumbnailLoader/load_segments.ts +++ b/src/experimental/tools/VideoThumbnailLoader/load_segments.ts @@ -1,9 +1,10 @@ import { combineLatest, Observable } from "rxjs"; -import { - ISegmentLoaderContent, - ISegmentLoaderEvent, -} from "../../../core/fetchers/segment/create_segment_loader"; +import { ISegmentFetcher } from "../../../core/fetchers/segment/segment_fetcher"; import { ISegment } from "../../../manifest"; +import { + ISegmentParserParsedInitSegment, + ISegmentParserParsedSegment, +} from "../../../transports"; import { createRequest, freeRequest } from "./create_request"; import getCompleteSegmentId from "./get_complete_segment_id"; import { IContentInfos } from "./types"; @@ -18,22 +19,22 @@ import { IContentInfos } from "./types"; */ export default function loadSegments( segments: ISegment[], - segmentLoader: ( - x : ISegmentLoaderContent - ) => Observable>, + segmentFetcher : ISegmentFetcher, contentInfos: IContentInfos -): Observable> { +): Observable | + ISegmentParserParsedInitSegment; +}>> { return combineLatest( segments.map((segment) => { return new Observable<{ segment: ISegment; - data: Uint8Array; } + data: ISegmentParserParsedSegment | + ISegmentParserParsedInitSegment; } >((obs) => { const completeSegmentId = getCompleteSegmentId(contentInfos, segment); - const request = createRequest(segmentLoader, contentInfos, segment); + const request = createRequest(segmentFetcher, contentInfos, segment); if (request.error !== undefined) { freeRequest(completeSegmentId); diff --git a/src/experimental/tools/VideoThumbnailLoader/push_data.ts b/src/experimental/tools/VideoThumbnailLoader/push_data.ts index b647f4039e..4985009abe 100644 --- a/src/experimental/tools/VideoThumbnailLoader/push_data.ts +++ b/src/experimental/tools/VideoThumbnailLoader/push_data.ts @@ -1,5 +1,4 @@ import { - EMPTY, Observable, } from "rxjs"; import { AudioVideoSegmentBuffer } from "../../../core/segment_buffers/implementations"; @@ -9,7 +8,7 @@ import Manifest, { Period, Representation, } from "../../../manifest"; -import { IThumbnailLoaderSegmentParser } from "./types"; +import { ISegmentParserParsedSegment } from "../../../transports"; /** * Push data to the video source buffer. @@ -27,18 +26,9 @@ export default function pushData( segment: ISegment; start: number; end: number; }, - segmentParser: IThumbnailLoaderSegmentParser, - responseData: Uint8Array, + parsed: ISegmentParserParsedSegment, videoSourceBuffer: AudioVideoSegmentBuffer ): Observable { - const parsed = segmentParser({ - response: { data: responseData, - isChunked: false }, - content: inventoryInfos, - }); - if (parsed.segmentType !== "media") { - return EMPTY; - } const { chunkData, appendWindow } = parsed; const segmentData = chunkData instanceof ArrayBuffer ? new Uint8Array(chunkData) : chunkData; diff --git a/src/experimental/tools/VideoThumbnailLoader/thumbnail_loader.ts b/src/experimental/tools/VideoThumbnailLoader/thumbnail_loader.ts index 14c708651e..b61f1920f5 100644 --- a/src/experimental/tools/VideoThumbnailLoader/thumbnail_loader.ts +++ b/src/experimental/tools/VideoThumbnailLoader/thumbnail_loader.ts @@ -32,7 +32,9 @@ import { tap, } from "rxjs/operators"; import Player from "../../../core/api"; -import createSegmentLoader from "../../../core/fetchers/segment/create_segment_loader"; +import createSegmentFetcher, { + ISegmentFetcher, +} from "../../../core/fetchers/segment/segment_fetcher"; import log from "../../../log"; import { ISegment } from "../../../manifest"; import objectAssign from "../../../utils/object_assign"; @@ -206,22 +208,21 @@ export default class VideoThumbnailLoader { }) ); - const segmentLoader = createSegmentLoader( - loader.video.loader, - undefined, + const segmentFetcher = createSegmentFetcher( + "video", + loader.video, + new Subject(), { baseDelay: 0, maxDelay: 0, maxRetryOffline: 0, maxRetryRegular: 0 } - ); - const { parser: segmentParser } = loader.video; + ) as ISegmentFetcher; const taskPromise: Promise = lastValueFrom(observableRace( abortError$, getInitializedSourceBuffer$(contentInfos, this._videoElement, - { segmentLoader, - segmentParser }).pipe( + segmentFetcher).pipe( mergeMap((videoSourceBuffer) => { const bufferCleaning$ = removeBufferAroundTime$(this._videoElement, videoSourceBuffer, @@ -229,7 +230,7 @@ export default class VideoThumbnailLoader { log.debug("VTL: Removing buffer around time.", time); const segmentsLoading$ = - loadSegments(segments, segmentLoader, contentInfos); + loadSegments(segments, segmentFetcher, contentInfos); return observableMerge(bufferCleaning$.pipe(ignoreElements()), segmentsLoading$ @@ -237,13 +238,15 @@ export default class VideoThumbnailLoader { mergeMap((arr) => { return combineLatest( arr.map(({ segment, data }) => { + if (data.segmentType === "init") { + throw new Error("Unexpected initialization segment parsed."); + } const start = segment.time / segment.timescale; const end = start + (segment.duration / segment.timescale); const inventoryInfos = objectAssign({ segment, start, end }, contentInfos); return pushData(inventoryInfos, - segmentParser, data, videoSourceBuffer) .pipe(tap(() => { diff --git a/src/experimental/tools/createMetaplaylist/get_duration_from_manifest.ts b/src/experimental/tools/createMetaplaylist/get_duration_from_manifest.ts index 8191dee412..322608c297 100644 --- a/src/experimental/tools/createMetaplaylist/get_duration_from_manifest.ts +++ b/src/experimental/tools/createMetaplaylist/get_duration_from_manifest.ts @@ -22,6 +22,8 @@ import { map } from "rxjs/operators"; import { IMetaPlaylist } from "../../../parsers/manifest/metaplaylist"; import isNonEmptyString from "../../../utils/is_non_empty_string"; import request from "../../../utils/request/xhr"; +import fromCancellablePromise from "../../../utils/rx-from_cancellable_promise"; +import TaskCanceller from "../../../utils/task_canceller"; const iso8601Duration = /^P(([\d.]*)Y)?(([\d.]*)M)?(([\d.]*)D)?T?(([\d.]*)H)?(([\d.]*)M)?(([\d.]*)S)?/; @@ -82,58 +84,65 @@ function getDurationFromManifest(url: string, return throwError(() => new Error("createMetaplaylist: Unknown transport type.")); } + const canceller = new TaskCanceller(); if (transport === "dash" || transport === "smooth") { - return request({ url, responseType: "document" }).pipe( - map(({ value }) => { - const { responseData } = value; - const root = responseData.documentElement; - if (transport === "dash") { - const dashDurationAttribute = root.getAttribute("mediaPresentationDuration"); - if (dashDurationAttribute === null) { - throw new Error("createMetaplaylist: No duration on DASH content."); - } - const periodElements = root.getElementsByTagName("Period"); - const firstDASHStartAttribute = periodElements[0]?.getAttribute("start"); - const firstDASHStart = - firstDASHStartAttribute !== null ? parseDuration(firstDASHStartAttribute) : - 0; - const dashDuration = parseDuration(dashDurationAttribute); - if (firstDASHStart === null || dashDuration === null) { - throw new Error("createMetaplaylist: Cannot parse " + - "the duration from a DASH content."); - } - return dashDuration - firstDASHStart; + return fromCancellablePromise( + canceller, + () => request({ url, + responseType: "document", + cancelSignal: canceller.signal }) + ).pipe(map((response) => { + const { responseData } = response; + const root = responseData.documentElement; + if (transport === "dash") { + const dashDurationAttribute = root.getAttribute("mediaPresentationDuration"); + if (dashDurationAttribute === null) { + throw new Error("createMetaplaylist: No duration on DASH content."); } - // smooth - const smoothDurationAttribute = root.getAttribute("Duration"); - const smoothTimeScaleAttribute = root.getAttribute("TimeScale"); - if (smoothDurationAttribute === null) { - throw new Error("createMetaplaylist: No duration on smooth content."); + const periodElements = root.getElementsByTagName("Period"); + const firstDASHStartAttribute = periodElements[0]?.getAttribute("start"); + const firstDASHStart = + firstDASHStartAttribute !== null ? parseDuration(firstDASHStartAttribute) : + 0; + const dashDuration = parseDuration(dashDurationAttribute); + if (firstDASHStart === null || dashDuration === null) { + throw new Error("createMetaplaylist: Cannot parse " + + "the duration from a DASH content."); } - const timescale = smoothTimeScaleAttribute !== null ? - parseInt(smoothTimeScaleAttribute, 10) : - 10000000; - return (parseInt(smoothDurationAttribute, 10)) / timescale; - }) - ); + return dashDuration - firstDASHStart; + } + // smooth + const smoothDurationAttribute = root.getAttribute("Duration"); + const smoothTimeScaleAttribute = root.getAttribute("TimeScale"); + if (smoothDurationAttribute === null) { + throw new Error("createMetaplaylist: No duration on smooth content."); + } + const timescale = smoothTimeScaleAttribute !== null ? + parseInt(smoothTimeScaleAttribute, 10) : + 10000000; + return (parseInt(smoothDurationAttribute, 10)) / timescale; + })); } // metaplaylist - return request({ url, responseType: "text" }).pipe( - map(({ value }) => { - const { responseData } = value; - const metaplaylist = JSON.parse(responseData) as IMetaPlaylist; - if (metaplaylist.contents === undefined || - metaplaylist.contents.length === undefined || - metaplaylist.contents.length === 0) { - throw new Error("createMetaplaylist: No duration on Metaplaylist content."); - } - const { contents } = metaplaylist; - const lastEnd = contents[contents.length - 1].endTime; - const firstStart = contents[0].startTime; - return lastEnd - firstStart; - }) - ); + return fromCancellablePromise( + canceller, + () => request({ url, + responseType: "text", + cancelSignal: canceller.signal }) + ).pipe(map((response) => { + const { responseData } = response; + const metaplaylist = JSON.parse(responseData) as IMetaPlaylist; + if (metaplaylist.contents === undefined || + metaplaylist.contents.length === undefined || + metaplaylist.contents.length === 0) { + throw new Error("createMetaplaylist: No duration on Metaplaylist content."); + } + const { contents } = metaplaylist; + const lastEnd = contents[contents.length - 1].endTime; + const firstStart = contents[0].startTime; + return lastEnd - firstStart; + })); } export default getDurationFromManifest; diff --git a/src/transports/dash/add_segment_integrity_checks_to_loader.ts b/src/transports/dash/add_segment_integrity_checks_to_loader.ts index 383cf9bf94..49272f608c 100644 --- a/src/transports/dash/add_segment_integrity_checks_to_loader.ts +++ b/src/transports/dash/add_segment_integrity_checks_to_loader.ts @@ -14,39 +14,80 @@ * limitations under the License. */ -import { tap } from "rxjs/operators"; -import { - ISegmentLoader, -} from "../types"; +import TaskCanceller, { + CancellationError, +} from "../../utils/task_canceller"; +import { ISegmentLoader } from "../types"; import checkISOBMFFIntegrity from "../utils/check_isobmff_integrity"; import inferSegmentContainer from "../utils/infer_segment_container"; /** * Add multiple checks on the response given by the `segmentLoader` in argument. - * If the response appear to be corrupted, this Observable will throw an error - * with an `INTEGRITY_ERROR` code. + * If the response appear to be corrupted, the returned Promise will reject with + * an error with an `INTEGRITY_ERROR` code. * @param {Function} segmentLoader * @returns {Function} */ -export default function addSegmentIntegrityChecks( - segmentLoader : ISegmentLoader -) : ISegmentLoader; -export default function addSegmentIntegrityChecks( - segmentLoader : ISegmentLoader -) : ISegmentLoader< ArrayBuffer | Uint8Array | string | null>; -export default function addSegmentIntegrityChecks( - segmentLoader : ISegmentLoader -) : ISegmentLoader -{ - return (content) => segmentLoader(content).pipe(tap((res) => { - if ((res.type === "data-loaded" || res.type === "data-chunk") && - res.value.responseData !== null && - typeof res.value.responseData !== "string" && - inferSegmentContainer(content.adaptation.type, - content.representation) === "mp4") - { - checkISOBMFFIntegrity(new Uint8Array(res.value.responseData), - content.segment.isInit); - } - })); +export default function addSegmentIntegrityChecks( + segmentLoader : ISegmentLoader +) : ISegmentLoader { + return (url, content, initialCancelSignal, callbacks) => { + return new Promise((res, rej) => { + + const canceller = new TaskCanceller(); + const unregisterCancelLstnr = initialCancelSignal + .register(function onCheckCancellation(err : CancellationError) { + canceller.cancel(); + rej(err); + }); + + /** + * If the data's seems to be corrupted, cancel the loading task and reject + * with an `INTEGRITY_ERROR` error. + * @param {*} data + */ + function cancelAndRejectOnBadIntegrity(data : T) : void { + if (!(data instanceof Array) && !(data instanceof Uint8Array) || + inferSegmentContainer(content.adaptation.type, + content.representation) !== "mp4") + { + return; + } + try { + checkISOBMFFIntegrity(new Uint8Array(data), content.segment.isInit); + } catch (err) { + unregisterCancelLstnr(); + canceller.cancel(); + rej(err); + } + } + + segmentLoader(url, content, canceller.signal, { + ...callbacks, + onNewChunk(data) { + cancelAndRejectOnBadIntegrity(data); + if (!canceller.isUsed) { + callbacks.onNewChunk(data); + } + }, + }).then((info) => { + if (canceller.isUsed) { + return; + } + unregisterCancelLstnr(); + + if (info.resultType === "segment-loaded") { + cancelAndRejectOnBadIntegrity(info.resultData.responseData); + } + res(info); + + }, (error : unknown) => { + // The segmentLoader's cancellations cases are all handled here + if (!TaskCanceller.isCancellationError(error)) { + unregisterCancelLstnr(); + rej(error); + } + }); + }); + }; } diff --git a/src/transports/dash/image_pipelines.ts b/src/transports/dash/image_pipelines.ts index aa579cb686..564580f568 100644 --- a/src/transports/dash/image_pipelines.ts +++ b/src/transports/dash/image_pipelines.ts @@ -14,37 +14,51 @@ * limitations under the License. */ -import { - Observable, - of as observableOf, -} from "rxjs"; +import PPromise from "pinkie"; import features from "../../features"; import request from "../../utils/request"; import takeFirstSet from "../../utils/take_first_set"; +import { CancellationSignal } from "../../utils/task_canceller"; import { IImageTrackSegmentData, - ISegmentLoaderArguments, - ISegmentLoaderEvent, - ISegmentParserArguments, + ILoadedImageSegmentFormat, + ISegmentContext, + ISegmentLoaderCallbacks, + ISegmentLoaderResultChunkedComplete, + ISegmentLoaderResultSegmentCreated, + ISegmentLoaderResultSegmentLoaded, ISegmentParserParsedInitSegment, ISegmentParserParsedSegment, } from "../types"; /** * Loads an image segment. - * @param {Object} args - * @returns {Observable} + * @param {string|null} url + * @param {Object} content + * @param {Object} cancelSignal + * @param {Object} callbacks + * @returns {Promise} */ export function imageLoader( - { segment, url } : ISegmentLoaderArguments -) : Observable< ISegmentLoaderEvent< ArrayBuffer | null > > { + url : string | null, + content : ISegmentContext, + cancelSignal : CancellationSignal, + callbacks : ISegmentLoaderCallbacks +) : Promise | + ISegmentLoaderResultSegmentCreated | + ISegmentLoaderResultChunkedComplete> +{ + const { segment } = content; if (segment.isInit || url === null) { - return observableOf({ type: "data-created" as const, - value: { responseData: null } }); + return PPromise.resolve({ resultType: "segment-created", + resultData: null }); } return request({ url, responseType: "arraybuffer", - sendProgressEvents: true }); + onProgress: callbacks.onProgress, + cancelSignal }) + .then((data) => ({ resultType: "segment-loaded", + resultData: data })); } /** @@ -53,13 +67,13 @@ export function imageLoader( * @returns {Object} */ export function imageParser( - { response, - content } : ISegmentParserArguments -) : ISegmentParserParsedInitSegment | - ISegmentParserParsedSegment -{ + loadedSegment : { data : ArrayBuffer | Uint8Array | null; + isChunked : boolean; }, + content : ISegmentContext +) : ISegmentParserParsedSegment< IImageTrackSegmentData | null > | + ISegmentParserParsedInitSegment< null > { const { segment, period } = content; - const { data, isChunked } = response; + const { data, isChunked } = loadedSegment; if (content.segment.isInit) { // image init segment has no use return { segmentType: "init", diff --git a/src/transports/dash/init_segment_loader.ts b/src/transports/dash/init_segment_loader.ts index 55df9f52b7..069e446d53 100644 --- a/src/transports/dash/init_segment_loader.ts +++ b/src/transports/dash/init_segment_loader.ts @@ -14,71 +14,94 @@ * limitations under the License. */ -import { - combineLatest as observableCombineLatest, - Observable, -} from "rxjs"; -import { map } from "rxjs/operators"; +import PPromise from "pinkie"; +import { ISegment } from "../../manifest"; import { concat } from "../../utils/byte_parsing"; -import xhr from "../../utils/request"; +import request from "../../utils/request"; +import { CancellationSignal } from "../../utils/task_canceller"; import { - ISegmentLoaderArguments, - ISegmentLoaderEvent, + ISegmentLoaderCallbacks, + ISegmentLoaderResultChunkedComplete, + ISegmentLoaderResultSegmentCreated, + ISegmentLoaderResultSegmentLoaded, } from "../types"; import byteRange from "../utils/byte_range"; /** * Perform a request for an initialization segment, agnostic to the container. * @param {string} url - * @param {Object} content + * @param {Object} segment + * @param {CancellationSignal} cancelSignal + * @param {Object} callbacks + * @returns {Promise} */ export default function initSegmentLoader( url : string, - { segment } : ISegmentLoaderArguments -) : Observable< ISegmentLoaderEvent< ArrayBuffer | Uint8Array >> { + segment : ISegment, + cancelSignal : CancellationSignal, + callbacks : ISegmentLoaderCallbacks +) : Promise | + ISegmentLoaderResultSegmentCreated | + ISegmentLoaderResultChunkedComplete> +{ if (segment.range === undefined) { - return xhr({ url, responseType: "arraybuffer", sendProgressEvents: true }); + return request({ url, + responseType: "arraybuffer", + cancelSignal, + onProgress: callbacks.onProgress }) + .then(data => ({ resultType: "segment-loaded", + resultData: data })); } if (segment.indexRange === undefined) { - return xhr({ url, - headers: { Range: byteRange(segment.range) }, - responseType: "arraybuffer", - sendProgressEvents: true }); + return request({ url, + headers: { Range: byteRange(segment.range) }, + responseType: "arraybuffer", + cancelSignal, + onProgress: callbacks.onProgress }) + .then(data => ({ resultType: "segment-loaded", + resultData: data })); } // range and indexRange are contiguous (99% of the cases) if (segment.range[1] + 1 === segment.indexRange[0]) { - return xhr({ url, - headers: { Range: byteRange([segment.range[0], - segment.indexRange[1] ]) }, - responseType: "arraybuffer", - sendProgressEvents: true }); + return request({ url, + headers: { Range: byteRange([segment.range[0], + segment.indexRange[1] ]) }, + responseType: "arraybuffer", + cancelSignal, + onProgress: callbacks.onProgress }) + .then(data => ({ resultType: "segment-loaded", + resultData: data })); } - const rangeRequest$ = xhr({ url, - headers: { Range: byteRange(segment.range) }, - responseType: "arraybuffer", - sendProgressEvents: false }); - const indexRequest$ = xhr({ url, - headers: { Range: byteRange(segment.indexRange) }, - responseType: "arraybuffer", - sendProgressEvents: false }); - return observableCombineLatest([rangeRequest$, indexRequest$]) - .pipe(map(([initData, indexData]) => { - const data = concat(new Uint8Array(initData.value.responseData), - new Uint8Array(indexData.value.responseData)); + const rangeRequest$ = request({ url, + headers: { Range: byteRange(segment.range) }, + responseType: "arraybuffer", + cancelSignal, + onProgress: callbacks.onProgress }); + const indexRequest$ = request({ url, + headers: { Range: byteRange(segment.indexRange) }, + responseType: "arraybuffer", + cancelSignal, + onProgress: callbacks.onProgress }); + + return PPromise.all([rangeRequest$, indexRequest$]) + .then(([ initData, indexData ]) => { + const data = concat(new Uint8Array(initData.responseData), + new Uint8Array(indexData.responseData)); + + const sendingTime = Math.min(initData.sendingTime, + indexData.sendingTime); + const receivedTime = Math.max(initData.receivedTime, + indexData.receivedTime); + return { resultType: "segment-loaded" as const, + resultData: { url, + responseData: data, + size: initData.size + indexData.size, + duration: receivedTime - sendingTime, + sendingTime, + receivedTime } }; - const sendingTime = Math.min(initData.value.sendingTime, - indexData.value.sendingTime); - const receivedTime = Math.max(initData.value.receivedTime, - indexData.value.receivedTime); - return { type: "data-loaded", - value: { url, - responseData: data, - size: initData.value.size + indexData.value.size, - duration: receivedTime - sendingTime, - sendingTime, - receivedTime } }; - })); + }); } diff --git a/src/transports/dash/low_latency_segment_loader.ts b/src/transports/dash/low_latency_segment_loader.ts index 7d046683b3..736bcd7a53 100644 --- a/src/transports/dash/low_latency_segment_loader.ts +++ b/src/transports/dash/low_latency_segment_loader.ts @@ -14,86 +14,70 @@ * limitations under the License. */ -import { - Observable, - of as observableOf, -} from "rxjs"; -import { - mergeMap, - scan, -} from "rxjs/operators"; -import log from "../../log"; import { concat } from "../../utils/byte_parsing"; import fetchRequest, { - IDataChunk, - IDataComplete, + IFetchedDataObject, } from "../../utils/request/fetch"; +import { CancellationSignal } from "../../utils/task_canceller"; import { - ILoaderProgressEvent, - ISegmentLoaderArguments, - ISegmentLoaderChunkEvent, - ISegmentLoaderEvent, + ISegmentContext, + ISegmentLoaderCallbacks, + ISegmentLoaderResultChunkedComplete, } from "../types"; import byteRange from "../utils/byte_range"; import extractCompleteChunks from "./extract_complete_chunks"; +/** + * Load segments through a "chunk" mode (decodable chunk by decodable chunk). + * + * This method is particularly adapted to low-latency streams. + * + * @param {string} url - URL of the segment to download. + * @param {Object} content - Context of the segment needed. + * @param {Object} callbacks + * @param {CancellationSignal} cancelSignal + * @returns {Promise} + */ export default function lowLatencySegmentLoader( url : string, - args : ISegmentLoaderArguments -) : Observable< ISegmentLoaderEvent > { - // Items emitted after processing fetch events - interface IScannedChunk { - event: IDataChunk | IDataComplete | null; // Event received from fetch - completeChunks: Uint8Array[]; // Complete chunks received on the event - partialChunk: Uint8Array | null; // Remaining incomplete chunk received on the event - } - - const { segment } = args; + content : ISegmentContext, + callbacks : ISegmentLoaderCallbacks, + cancelSignal : CancellationSignal +) : Promise { + const { segment } = content; const headers = segment.range !== undefined ? { Range: byteRange(segment.range) } : undefined; + let partialChunk : Uint8Array | null = null; - return fetchRequest({ url, headers }) - .pipe( - scan((acc, evt) => { - if (evt.type === "data-complete") { - if (acc.partialChunk !== null) { - log.warn("DASH Pipelines: remaining chunk does not belong to any segment"); - } - return { event: evt, completeChunks: [], partialChunk: null }; - } - - const data = new Uint8Array(evt.value.chunk); - const concatenated = acc.partialChunk !== null ? concat(acc.partialChunk, - data) : - data; - const [ completeChunks, - partialChunk ] = extractCompleteChunks(concatenated); - return { event: evt, completeChunks, partialChunk }; - }, { event: null, completeChunks: [], partialChunk: null }), + /** + * Called each time `fetch` has new data available. + * @param {Object} info + */ + function onData(info : IFetchedDataObject) : void { + const chunk = new Uint8Array(info.chunk); + const concatenated = partialChunk !== null ? concat(partialChunk, chunk) : + chunk; + const res = extractCompleteChunks(concatenated); + const completeChunks = res[0]; + partialChunk = res[1]; + for (let i = 0; i < completeChunks.length; i++) { + callbacks.onNewChunk(completeChunks[i]); + if (cancelSignal.isCancelled) { + return; + } + } + callbacks.onProgress({ duration: info.duration, + size: info.size, + totalSize: info.totalSize }); + if (cancelSignal.isCancelled) { + return; + } + } - mergeMap((evt : IScannedChunk) => { - const emitted : Array = []; - for (let i = 0; i < evt.completeChunks.length; i++) { - emitted.push({ type: "data-chunk", - value: { responseData: evt.completeChunks[i] } }); - } - const { event } = evt; - if (event !== null && event.type === "data-chunk") { - const { value } = event; - emitted.push({ type: "progress", - value: { duration: value.duration, - size: value.size, - totalSize: value.totalSize } }); - } else if (event !== null && event.type === "data-complete") { - const { value } = event; - emitted.push({ type: "data-chunk-complete", - value: { duration: value.duration, - receivedTime: value.receivedTime, - sendingTime: value.sendingTime, - size: value.size, - url: value.url } }); - } - return observableOf(...emitted); - })); + return fetchRequest({ url, + headers, + onData, + cancelSignal }) + .then((res) => ({ resultType: "chunk-complete" as const, + resultData: res })); } diff --git a/src/transports/dash/manifest_parser.ts b/src/transports/dash/manifest_parser.ts index 03fbc6cbc5..f371ee2ed9 100644 --- a/src/transports/dash/manifest_parser.ts +++ b/src/transports/dash/manifest_parser.ts @@ -14,18 +14,7 @@ * limitations under the License. */ -import { - combineLatest as observableCombineLatest, - concat as observableConcat, - from as observableFrom, - Observable, - of as observableOf, -} from "rxjs"; -import { - filter, - map, - mergeMap, -} from "rxjs/operators"; +import PPromise from "pinkie"; import features from "../../features"; import log from "../../log"; import Manifest from "../../manifest"; @@ -36,23 +25,24 @@ import { strToUtf8, utf8ToStr, } from "../../utils/string_parsing"; +import { CancellationSignal } from "../../utils/task_canceller"; import { - ILoaderDataLoadedValue, - IManifestParserArguments, - IManifestParserResponseEvent, - IManifestParserWarningEvent, + IManifestParserOptions, + IManifestParserRequestScheduler, + IManifestParserResult, + IRequestedData, ITransportOptions, } from "../types"; -import returnParsedManifest from "../utils/return_parsed_manifest"; -/** - * @param {Object} options - * @returns {Function} - */ export default function generateManifestParser( options : ITransportOptions -) : (x : IManifestParserArguments) => Observable +) : ( + manifestData : IRequestedData, + parserOptions : IManifestParserOptions, + onWarnings : (warnings : Error[]) => void, + cancelSignal : CancellationSignal, + scheduleRequest : IManifestParserRequestScheduler + ) => IManifestParserResult | Promise { const { aggressiveMode, referenceDateTime } = options; @@ -60,24 +50,26 @@ export default function generateManifestParser( options.serverSyncInfos.serverTimestamp - options.serverSyncInfos.clientTime : undefined; return function manifestParser( - args : IManifestParserArguments - ) : Observable { - const { response, - scheduleRequest, - url: loaderURL, - externalClockOffset: argClockOffset } = args; - const url = response.url ?? loaderURL; - const { responseData } = response; + manifestData : IRequestedData, + parserOptions : IManifestParserOptions, + onWarnings : (warnings : Error[]) => void, + cancelSignal : CancellationSignal, + scheduleRequest : IManifestParserRequestScheduler + ) : IManifestParserResult | Promise { + const { responseData } = manifestData; + const argClockOffset = parserOptions.externalClockOffset; + const url = manifestData.url ?? parserOptions.originalUrl; const optAggressiveMode = aggressiveMode === true; const externalClockOffset = serverTimeOffset ?? argClockOffset; - const unsafelyBaseOnPreviousManifest = args.unsafeMode ? args.previousManifest : - null; - const parserOpts = { aggressiveMode: optAggressiveMode, - unsafelyBaseOnPreviousManifest, - url, - referenceDateTime, - externalClockOffset }; + const unsafelyBaseOnPreviousManifest = parserOptions.unsafeMode ? + parserOptions.previousManifest : + null; + const dashParserOpts = { aggressiveMode: optAggressiveMode, + unsafelyBaseOnPreviousManifest, + url, + referenceDateTime, + externalClockOffset }; const parsers = features.dashParsers; if (parsers.wasm === null || @@ -96,22 +88,22 @@ export default function generateManifestParser( if (parsers.wasm.status === "initialized") { log.debug("DASH: Running WASM MPD Parser."); - const parsed = parsers.wasm.runWasmParser(manifestAB, parserOpts); + const parsed = parsers.wasm.runWasmParser(manifestAB, dashParserOpts); return processMpdParserResponse(parsed); } else { log.debug("DASH: Awaiting WASM initialization before parsing the MPD."); const initProm = parsers.wasm.waitForInitialization() .catch(() => { /* ignore errors, we will check the status later */ }); - return observableFrom(initProm).pipe(mergeMap(() => { + return initProm.then(() => { if (parsers.wasm === null || parsers.wasm.status !== "initialized") { log.warn("DASH: WASM MPD parser initialization failed. " + "Running JS parser instead"); return runDefaultJsParser(); } log.debug("DASH: Running WASM MPD Parser."); - const parsed = parsers.wasm.runWasmParser(manifestAB, parserOpts); + const parsed = parsers.wasm.runWasmParser(manifestAB, dashParserOpts); return processMpdParserResponse(parsed); - })); + }); } } @@ -126,7 +118,7 @@ export default function generateManifestParser( throw new Error("No MPD parser is imported"); } const manifestDoc = getManifestAsDocument(responseData); - const parsedManifest = parsers.js(manifestDoc, parserOpts); + const parsedManifest = parsers.js(manifestDoc, dashParserOpts); return processMpdParserResponse(parsedManifest); } @@ -138,50 +130,54 @@ export default function generateManifestParser( */ function processMpdParserResponse( parserResponse : IDashParserResponse | IDashParserResponse - ) : Observable { + ) : IManifestParserResult | Promise { if (parserResponse.type === "done") { - const { warnings, parsed } = parserResponse.value; - const warningEvents = warnings.map(warning => ({ type: "warning" as const, - value: warning })); - const manifest = new Manifest(parsed, options); - return observableConcat(observableOf(...warningEvents), - returnParsedManifest(manifest, url)); + if (parserResponse.value.warnings.length > 0) { + onWarnings(parserResponse.value.warnings); + } + if (cancelSignal.isCancelled) { + return PPromise.reject(cancelSignal.cancellationError); + } + const manifest = new Manifest(parserResponse.value.parsed, options); + return { manifest, url }; } const { value } = parserResponse; - const externalResources$ = value.urls.map(resourceUrl => - scheduleRequest(() => - request({ - url: resourceUrl, - responseType: value.format === "string" ? "text" : - "arraybuffer", - }).pipe( - filter((e) => e.type === "data-loaded"), - map((e) : ILoaderDataLoadedValue => e.value)))); + const externalResources = value.urls.map(resourceUrl => { + return scheduleRequest(() => { + const req = value.format === "string" ? + request({ url: resourceUrl, + responseType: "text" as const, + cancelSignal }) : + request({ url: resourceUrl, + responseType: "arraybuffer" as const, + cancelSignal }); + return req; + }); + }); - return observableCombineLatest(externalResources$) - .pipe(mergeMap(loadedResources => { - if (value.format === "string") { - const resources = loadedResources.map(resource => { - if (typeof resource.responseData !== "string") { - throw new Error("External DASH resources should have been a string"); - } - // Normally not needed but TypeScript is just dumb here - return objectAssign(resource, { responseData: resource.responseData }); - }); - return processMpdParserResponse(value.continue(resources)); - } else { - const resources = loadedResources.map(resource => { - if (!(resource.responseData instanceof ArrayBuffer)) { - throw new Error("External DASH resources should have been ArrayBuffers"); - } - // Normally not needed but TypeScript is just dumb here - return objectAssign(resource, { responseData: resource.responseData }); - }); - return processMpdParserResponse(value.continue(resources)); - } - })); + return PPromise.all(externalResources).then(loadedResources => { + if (value.format === "string") { + const resources = loadedResources.map(resource => { + if (typeof resource.responseData !== "string") { + throw new Error("External DASH resources should have been a string"); + } + // Normally not needed but TypeScript is just dumb here + return objectAssign(resource, { responseData: resource.responseData }); + }); + return processMpdParserResponse(value.continue(resources)); + } else { + const resources = loadedResources.map(resource => { + if (!(resource.responseData instanceof ArrayBuffer)) { + throw new Error("External DASH resources should have been ArrayBuffers"); + } + // Normally not needed but TypeScript is just dumb here + return objectAssign(resource, { responseData: resource.responseData }); + }); + return processMpdParserResponse(value.continue(resources)); + } + }); } }; } diff --git a/src/transports/dash/pipelines.ts b/src/transports/dash/pipelines.ts index f078e6b577..24619cd1b0 100644 --- a/src/transports/dash/pipelines.ts +++ b/src/transports/dash/pipelines.ts @@ -48,16 +48,16 @@ export default function(options : ITransportOptions) : ITransportPipelines { const textTrackLoader = generateTextTrackLoader(options); const textTrackParser = generateTextTrackParser(options); - return { manifest: { loader: manifestLoader, - parser: manifestParser }, - audio: { loader: segmentLoader, - parser: audioVideoSegmentParser }, - video: { loader: segmentLoader, - parser: audioVideoSegmentParser }, - text: { loader: textTrackLoader, - parser: textTrackParser }, - image: { loader: imageLoader, - parser: imageParser } }; + return { manifest: { loadManifest: manifestLoader, + parseManifest: manifestParser }, + audio: { loadSegment: segmentLoader, + parseSegment: audioVideoSegmentParser }, + video: { loadSegment: segmentLoader, + parseSegment: audioVideoSegmentParser }, + text: { loadSegment: textTrackLoader, + parseSegment: textTrackParser }, + image: { loadSegment: imageLoader, + parseSegment: imageParser } }; } /** diff --git a/src/transports/dash/segment_loader.ts b/src/transports/dash/segment_loader.ts index 08f1bb74d0..60288e4e49 100644 --- a/src/transports/dash/segment_loader.ts +++ b/src/transports/dash/segment_loader.ts @@ -14,23 +14,25 @@ * limitations under the License. */ -import { - Observable, - Observer, - of as observableOf, -} from "rxjs"; +import PPromise from "pinkie"; import { CustomLoaderError } from "../../errors"; -import xhr, { +import request, { fetchIsSupported, } from "../../utils/request"; +import { + CancellationError, + CancellationSignal, +} from "../../utils/task_canceller"; import warnOnce from "../../utils/warn_once"; import { CustomSegmentLoader, - ILoaderProgressEvent, + ILoadedAudioVideoSegmentFormat, + ISegmentContext, ISegmentLoader, - ISegmentLoaderArguments, - ISegmentLoaderDataLoadedEvent, - ISegmentLoaderEvent, + ISegmentLoaderCallbacks, + ISegmentLoaderResultChunkedComplete, + ISegmentLoaderResultSegmentCreated, + ISegmentLoaderResultSegmentLoaded, } from "../types"; import byteRange from "../utils/byte_range"; import inferSegmentContainer from "../utils/infer_segment_container"; @@ -38,42 +40,50 @@ import addSegmentIntegrityChecks from "./add_segment_integrity_checks_to_loader" import initSegmentLoader from "./init_segment_loader"; import lowLatencySegmentLoader from "./low_latency_segment_loader"; -type ICustomSegmentLoaderObserver = - Observer>; - /** * Segment loader triggered if there was no custom-defined one in the API. - * @param {Object} opt - * @returns {Observable} + * @param {string} uri + * @param {Object} content + * @param {boolean} lowLatencyMode + * @param {Object} callbacks + * @param {Object} cancelSignal + * @returns {Promise} */ -function regularSegmentLoader( +export function regularSegmentLoader( url : string, - args : ISegmentLoaderArguments, - lowLatencyMode : boolean -) : Observable< ISegmentLoaderEvent> { - - if (args.segment.isInit) { - return initSegmentLoader(url, args); + content : ISegmentContext, + lowLatencyMode : boolean, + callbacks : ISegmentLoaderCallbacks, + cancelSignal : CancellationSignal +) : Promise | + ISegmentLoaderResultSegmentCreated | + ISegmentLoaderResultChunkedComplete> +{ + if (content.segment.isInit) { + return initSegmentLoader(url, content.segment, cancelSignal, callbacks); } - const containerType = inferSegmentContainer(args.adaptation.type, args.representation); + const containerType = inferSegmentContainer(content.adaptation.type, + content.representation); if (lowLatencyMode && (containerType === "mp4" || containerType === undefined)) { if (fetchIsSupported()) { - return lowLatencySegmentLoader(url, args); + return lowLatencySegmentLoader(url, content, callbacks, cancelSignal); } else { warnOnce("DASH: Your browser does not have the fetch API. You will have " + "a higher chance of rebuffering when playing close to the live edge"); } } - const { segment } = args; - return xhr({ url, - responseType: "arraybuffer", - sendProgressEvents: true, - headers: segment.range !== undefined ? - { Range: byteRange(segment.range) } : - undefined }); + const { segment } = content; + return request({ url, + responseType: "arraybuffer", + headers: segment.range !== undefined ? + { Range: byteRange(segment.range) } : + undefined, + cancelSignal, + onProgress: callbacks.onProgress }) + .then((data) => ({ resultType: "segment-loaded", + resultData: data })); } /** @@ -86,7 +96,7 @@ export default function generateSegmentLoader( checkMediaSegmentIntegrity } : { lowLatencyMode: boolean; segmentLoader? : CustomSegmentLoader; checkMediaSegmentIntegrity? : boolean; } -) : ISegmentLoader< Uint8Array | ArrayBuffer | null > { +) : ISegmentLoader { return checkMediaSegmentIntegrity !== true ? segmentLoader : addSegmentIntegrityChecks(segmentLoader); @@ -95,16 +105,21 @@ export default function generateSegmentLoader( * @returns {Observable} */ function segmentLoader( - content : ISegmentLoaderArguments - ) : Observable< ISegmentLoaderEvent< Uint8Array | ArrayBuffer | null > > { - const { url } = content; + url : string | null, + content : ISegmentContext, + cancelSignal : CancellationSignal, + callbacks : ISegmentLoaderCallbacks + ) : Promise | + ISegmentLoaderResultSegmentCreated | + ISegmentLoaderResultChunkedComplete> + { if (url == null) { - return observableOf({ type: "data-created" as const, - value: { responseData: null } }); + return PPromise.resolve({ resultType: "segment-created", + resultData: null }); } if (lowLatencyMode || customSegmentLoader === undefined) { - return regularSegmentLoader(url, content, lowLatencyMode); + return regularSegmentLoader(url, content, lowLatencyMode, callbacks, cancelSignal); } const args = { adaptation: content.adaptation, @@ -115,9 +130,9 @@ export default function generateSegmentLoader( transport: "dash", url }; - return new Observable((obs : ICustomSegmentLoaderObserver) => { + return new Promise((res, rej) => { + /** `true` when the custom segmentLoader should not be active anymore. */ let hasFinished = false; - let hasFallbacked = false; /** * Callback triggered when the custom segment loader has a response. @@ -128,14 +143,15 @@ export default function generateSegmentLoader( size? : number; duration? : number; } ) => { - if (!hasFallbacked) { - hasFinished = true; - obs.next({ type: "data-loaded" as const, - value: { responseData: _args.data, - size: _args.size, - duration: _args.duration } }); - obs.complete(); + if (hasFinished || cancelSignal.isCancelled) { + return; } + hasFinished = true; + cancelSignal.deregister(abortCustomLoader); + res({ resultType: "segment-loaded", + resultData: { responseData: _args.data, + size: _args.size, + duration: _args.duration } }); }; /** @@ -143,23 +159,25 @@ export default function generateSegmentLoader( * @param {*} err - The corresponding error encountered */ const reject = (err = {}) : void => { - if (!hasFallbacked) { - hasFinished = true; - - // Format error and send it - const castedErr = err as (null | undefined | { message? : string; - canRetry? : boolean; - isOfflineError? : boolean; - xhr? : XMLHttpRequest; }); - const message = castedErr?.message ?? - "Unknown error when fetching a DASH segment through a " + - "custom segmentLoader."; - const emittedErr = new CustomLoaderError(message, - castedErr?.canRetry ?? false, - castedErr?.isOfflineError ?? false, - castedErr?.xhr); - obs.error(emittedErr); + if (hasFinished || cancelSignal.isCancelled) { + return; } + hasFinished = true; + cancelSignal.deregister(abortCustomLoader); + + // Format error and send it + const castedErr = err as (null | undefined | { message? : string; + canRetry? : boolean; + isOfflineError? : boolean; + xhr? : XMLHttpRequest; }); + const message = castedErr?.message ?? + "Unknown error when fetching a DASH segment through a " + + "custom segmentLoader."; + const emittedErr = new CustomLoaderError(message, + castedErr?.canRetry ?? false, + castedErr?.isOfflineError ?? false, + castedErr?.xhr); + rej(emittedErr); }; const progress = ( @@ -167,11 +185,12 @@ export default function generateSegmentLoader( size : number; totalSize? : number; } ) => { - if (!hasFallbacked) { - obs.next({ type: "progress", value: { duration: _args.duration, - size: _args.size, - totalSize: _args.totalSize } }); + if (hasFinished || cancelSignal.isCancelled) { + return; } + callbacks.onProgress({ duration: _args.duration, + size: _args.size, + totalSize: _args.totalSize }); }; /** @@ -179,26 +198,34 @@ export default function generateSegmentLoader( * the "regular" implementation */ const fallback = () => { - hasFallbacked = true; - const regular$ = regularSegmentLoader(url, content, lowLatencyMode); - - // HACK What is TypeScript/RxJS doing here?????? - /* eslint-disable import/no-deprecated */ - /* eslint-disable @typescript-eslint/ban-ts-comment */ - // @ts-ignore - regular$.subscribe(obs); - /* eslint-enable import/no-deprecated */ - /* eslint-enable @typescript-eslint/ban-ts-comment */ + if (hasFinished || cancelSignal.isCancelled) { + return; + } + hasFinished = true; + cancelSignal.deregister(abortCustomLoader); + regularSegmentLoader(url, content, lowLatencyMode, callbacks, cancelSignal) + .then(res, rej); }; - const callbacks = { reject, resolve, progress, fallback }; - const abort = customSegmentLoader(args, callbacks); + const customCallbacks = { reject, resolve, progress, fallback }; + const abort = customSegmentLoader(args, customCallbacks); - return () => { - if (!hasFinished && !hasFallbacked && typeof abort === "function") { + cancelSignal.register(abortCustomLoader); + + /** + * The logic to run when the custom loader is cancelled while pending. + * @param {Error} err + */ + function abortCustomLoader(err : CancellationError) { + if (hasFinished) { + return; + } + hasFinished = true; + if (typeof abort === "function") { abort(); } - }; + rej(err); + } }); } } diff --git a/src/transports/dash/segment_parser.ts b/src/transports/dash/segment_parser.ts index dd04fee59d..d312dcadcb 100644 --- a/src/transports/dash/segment_parser.ts +++ b/src/transports/dash/segment_parser.ts @@ -28,7 +28,8 @@ import { BaseRepresentationIndex } from "../../parsers/manifest/dash"; import isNullOrUndefined from "../../utils/is_null_or_undefined"; import takeFirstSet from "../../utils/take_first_set"; import { - ISegmentParserArguments, + ISegmentContext, + ISegmentParser, ISegmentParserParsedInitSegment, ISegmentParserParsedSegment, } from "../types"; @@ -42,27 +43,29 @@ import getEventsOutOfEMSGs from "./get_events_out_of_emsgs"; */ export default function generateAudioVideoSegmentParser( { __priv_patchLastSegmentInSidx } : { __priv_patchLastSegmentInSidx? : boolean } -) { +) : ISegmentParser< + ArrayBuffer | Uint8Array | null, + ArrayBuffer | Uint8Array | null +> { return function audioVideoSegmentParser( - { content, - response, - initTimescale } : ISegmentParserArguments< Uint8Array | - ArrayBuffer | - null > - ) : ISegmentParserParsedInitSegment | - ISegmentParserParsedSegment< Uint8Array | ArrayBuffer | null> { + loadedSegment : { data : ArrayBuffer | Uint8Array | null; + isChunked : boolean; }, + content : ISegmentContext, + initTimescale : number | undefined + ) : ISegmentParserParsedSegment< Uint8Array | ArrayBuffer | null > | + ISegmentParserParsedInitSegment< Uint8Array | ArrayBuffer | null > { const { period, adaptation, representation, segment, manifest } = content; - const { data, isChunked } = response; + const { data, isChunked } = loadedSegment; const appendWindow : [number, number | undefined] = [ period.start, period.end ]; if (data === null) { if (segment.isInit) { - return { segmentType: "init" as const, + return { segmentType: "init", initializationData: null, protectionDataUpdate: false, initTimescale: undefined }; } - return { segmentType: "media" as const, + return { segmentType: "media", chunkData: null, chunkInfos: null, chunkOffset: 0, diff --git a/src/transports/dash/text_loader.ts b/src/transports/dash/text_loader.ts index b5f05d77a3..dc4fcdd777 100644 --- a/src/transports/dash/text_loader.ts +++ b/src/transports/dash/text_loader.ts @@ -14,17 +14,20 @@ * limitations under the License. */ -import { - Observable, - of as observableOf, -} from "rxjs"; +import PPromise from "pinkie"; import request, { fetchIsSupported, } from "../../utils/request"; +import { CancellationSignal } from "../../utils/task_canceller"; import warnOnce from "../../utils/warn_once"; import { - ISegmentLoaderArguments, - ISegmentLoaderEvent, + ILoadedTextSegmentFormat, + ISegmentContext, + ISegmentLoader, + ISegmentLoaderCallbacks, + ISegmentLoaderResultChunkedComplete, + ISegmentLoaderResultSegmentCreated, + ISegmentLoaderResultSegmentLoaded, } from "../types"; import byteRange from "../utils/byte_range"; import inferSegmentContainer from "../utils/infer_segment_container"; @@ -39,54 +42,72 @@ import lowLatencySegmentLoader from "./low_latency_segment_loader"; */ export default function generateTextTrackLoader( { lowLatencyMode, - checkMediaSegmentIntegrity } : { lowLatencyMode : boolean; + checkMediaSegmentIntegrity } : { lowLatencyMode: boolean; checkMediaSegmentIntegrity? : boolean; } -) : (x : ISegmentLoaderArguments) => Observable< ISegmentLoaderEvent< ArrayBuffer | - Uint8Array | - string | - null > > { +) : ISegmentLoader { return checkMediaSegmentIntegrity !== true ? textTrackLoader : addSegmentIntegrityChecks(textTrackLoader); /** - * @param {Object} args - * @returns {Observable} + * @param {string|null} url + * @param {Object} content + * @param {Object} cancelSignal + * @param {Object} callbacks + * @returns {Promise} */ function textTrackLoader( - args : ISegmentLoaderArguments - ) : Observable< ISegmentLoaderEvent< ArrayBuffer | Uint8Array | string | null > > { - const { range } = args.segment; - const { url } = args; + url : string | null, + content : ISegmentContext, + cancelSignal : CancellationSignal, + callbacks : ISegmentLoaderCallbacks + ) : Promise | + ISegmentLoaderResultSegmentCreated | + ISegmentLoaderResultChunkedComplete> + { + const { adaptation, representation, segment } = content; + const { range } = segment; if (url === null) { - return observableOf({ type: "data-created", - value: { responseData: null } }); + return PPromise.resolve({ resultType: "segment-created", + resultData: null }); } - if (args.segment.isInit) { - return initSegmentLoader(url, args); + if (segment.isInit) { + return initSegmentLoader(url, segment, cancelSignal, callbacks); } - const containerType = inferSegmentContainer(args.adaptation.type, - args.representation); + const containerType = inferSegmentContainer(adaptation.type, representation); const seemsToBeMP4 = containerType === "mp4" || containerType === undefined; if (lowLatencyMode && seemsToBeMP4) { if (fetchIsSupported()) { - return lowLatencySegmentLoader(url, args); + return lowLatencySegmentLoader(url, content, callbacks, cancelSignal); } else { warnOnce("DASH: Your browser does not have the fetch API. You will have " + "a higher chance of rebuffering when playing close to the live edge"); } } - // ArrayBuffer when in mp4 to parse isobmff manually, text otherwise - const responseType = seemsToBeMP4 ? "arraybuffer" : - "text"; - return request({ url, - responseType, - headers: Array.isArray(range) ? - { Range: byteRange(range) } : - null, - sendProgressEvents: true }); + if (seemsToBeMP4) { + return request({ url, + responseType: "arraybuffer", + headers: Array.isArray(range) ? + { Range: byteRange(range) } : + null, + onProgress: callbacks.onProgress, + cancelSignal }) + .then((data) => ({ resultType: "segment-loaded", + resultData: data })); + } + + return request({ url, + responseType: "text", + headers: Array.isArray(range) ? + { Range: byteRange(range) } : + null, + onProgress: callbacks.onProgress, + cancelSignal }) + .then((data) => ({ resultType: "segment-loaded", + resultData: data })); + } } diff --git a/src/transports/dash/text_parser.ts b/src/transports/dash/text_parser.ts index 51f40e68e9..b2ef3b57ee 100644 --- a/src/transports/dash/text_parser.ts +++ b/src/transports/dash/text_parser.ts @@ -14,12 +14,6 @@ * limitations under the License. */ -import Manifest, { - Adaptation, - ISegment, - Period, - Representation, -} from "../../manifest"; import { getMDHDTimescale, getSegmentsFromSidx, @@ -31,7 +25,8 @@ import { } from "../../utils/string_parsing"; import takeFirstSet from "../../utils/take_first_set"; import { - ISegmentParserArguments, + ISegmentContext, + ISegmentParser, ISegmentParserParsedInitSegment, ISegmentParserParsedSegment, ITextTrackSegmentData, @@ -63,18 +58,13 @@ import { * @returns {Observable.} */ function parseISOBMFFEmbeddedTextTrack( - data : Uint8Array | ArrayBuffer | string, + data : ArrayBuffer | Uint8Array | string, isChunked : boolean, - content : { manifest : Manifest; - period : Period; - adaptation : Adaptation; - representation : Representation; - segment : ISegment; }, + content : ISegmentContext, initTimescale : number | undefined, __priv_patchLastSegmentInSidx? : boolean -) : ISegmentParserParsedInitSegment | - ISegmentParserParsedSegment -{ +) : ISegmentParserParsedSegment< ITextTrackSegmentData | null > | + ISegmentParserParsedInitSegment< null > { const { period, representation, segment } = content; const { isInit, indexRange } = segment; @@ -144,16 +134,11 @@ function parseISOBMFFEmbeddedTextTrack( * @returns {Observable.} */ function parsePlainTextTrack( - data : Uint8Array | ArrayBuffer | string, + data : ArrayBuffer | Uint8Array | string, isChunked : boolean, - content : { manifest : Manifest; - period : Period; - adaptation : Adaptation; - representation : Representation; - segment : ISegment; } -) : ISegmentParserParsedInitSegment | - ISegmentParserParsedSegment -{ + content : ISegmentContext +) : ISegmentParserParsedSegment< ITextTrackSegmentData | null > | + ISegmentParserParsedInitSegment< null > { const { period, segment } = content; const { timestampOffset = 0 } = segment; if (segment.isInit) { @@ -188,24 +173,22 @@ function parsePlainTextTrack( */ export default function generateTextTrackParser( { __priv_patchLastSegmentInSidx } : { __priv_patchLastSegmentInSidx? : boolean } -) { +) : ISegmentParser< ArrayBuffer | Uint8Array | string | null, + ITextTrackSegmentData | null > { /** * Parse TextTrack data. * @param {Object} infos * @returns {Observable.} */ return function textTrackParser( - { response, - content, - initTimescale } : ISegmentParserArguments< Uint8Array | - ArrayBuffer | - string | - null > - ) : ISegmentParserParsedInitSegment | - ISegmentParserParsedSegment - { + loadedSegment : { data : ArrayBuffer | Uint8Array | string | null; + isChunked : boolean; }, + content : ISegmentContext, + initTimescale : number | undefined + ) : ISegmentParserParsedSegment< ITextTrackSegmentData | null > | + ISegmentParserParsedInitSegment< null > { const { period, adaptation, representation, segment } = content; - const { data, isChunked } = response; + const { data, isChunked } = loadedSegment; if (data === null) { // No data, just return an empty placeholder object diff --git a/src/transports/local/pipelines.ts b/src/transports/local/pipelines.ts index 5342da2c6a..f2497f9140 100644 --- a/src/transports/local/pipelines.ts +++ b/src/transports/local/pipelines.ts @@ -19,23 +19,20 @@ * It always should be imported through the `features` object. */ -import { Observable } from "rxjs"; import Manifest from "../../manifest"; import parseLocalManifest, { ILocalManifest, } from "../../parsers/manifest/local"; import isNullOrUndefined from "../../utils/is_null_or_undefined"; +import { CancellationSignal } from "../../utils/task_canceller"; import { - IManifestLoaderArguments, - IManifestLoaderEvent, - IManifestParserArguments, - IManifestParserResponseEvent, - IManifestParserWarningEvent, + ILoadedManifestFormat, + IManifestParserResult, + IRequestedData, ITransportOptions, ITransportPipelines, } from "../types"; import callCustomManifestLoader from "../utils/call_custom_manifest_loader"; -import returnParsedManifest from "../utils/return_parsed_manifest"; import segmentLoader from "./segment_loader"; import segmentParser from "./segment_parser"; import textTrackParser from "./text_parser"; @@ -51,7 +48,10 @@ export default function getLocalManifestPipelines( const customManifestLoader = options.manifestLoader; const manifestPipeline = { - loader(args : IManifestLoaderArguments) : Observable< IManifestLoaderEvent > { + loadManifest( + url : string | undefined, + cancelSignal : CancellationSignal + ) : Promise> { if (isNullOrUndefined(customManifestLoader)) { throw new Error("A local Manifest is not loadable through regular HTTP(S) " + " calls. You have to set a `manifestLoader` when calling " + @@ -62,32 +62,30 @@ export default function getLocalManifestPipelines( () : never => { throw new Error("Cannot fallback from the `manifestLoader` of a " + "`local` transport"); - })(args); + })(url, cancelSignal); }, - parser( - { response } : IManifestParserArguments - ) : Observable { - const manifestData = response.responseData; + parseManifest(manifestData : IRequestedData) : IManifestParserResult { + const loadedManifest = manifestData.responseData; if (typeof manifestData !== "object") { throw new Error("Wrong format for the manifest data"); } - const parsed = parseLocalManifest(response.responseData as ILocalManifest); + const parsed = parseLocalManifest(loadedManifest as ILocalManifest); const manifest = new Manifest(parsed, options); - return returnParsedManifest(manifest); + return { manifest, url: undefined }; }, }; - const segmentPipeline = { loader: segmentLoader, - parser: segmentParser }; - const textTrackPipeline = { loader: segmentLoader, - parser: textTrackParser }; + const segmentPipeline = { loadSegment: segmentLoader, + parseSegment: segmentParser }; + const textTrackPipeline = { loadSegment: segmentLoader, + parseSegment: textTrackParser }; const imageTrackPipeline = { - loader: () : never => { + loadSegment: () : never => { throw new Error("Images track not supported in local transport."); }, - parser: () : never => { + parseSegment: () : never => { throw new Error("Images track not supported in local transport."); }, }; diff --git a/src/transports/local/segment_loader.ts b/src/transports/local/segment_loader.ts index 0054dbd409..6b9fa70ddd 100644 --- a/src/transports/local/segment_loader.ts +++ b/src/transports/local/segment_loader.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - Observable, - Observer, -} from "rxjs"; +import PPromise from "pinkie"; import { CustomLoaderError } from "../../errors"; import { ILocalManifestInitSegmentLoader, @@ -25,21 +22,26 @@ import { } from "../../parsers/manifest/local"; import isNullOrUndefined from "../../utils/is_null_or_undefined"; import { - ISegmentLoaderArguments, - ISegmentLoaderDataLoadedEvent, - ISegmentLoaderEvent, + CancellationError, + CancellationSignal, +} from "../../utils/task_canceller"; +import { + ISegmentContext, + ISegmentLoaderCallbacks, + ISegmentLoaderResultSegmentLoaded, } from "../types"; /** * @param {Function} customSegmentLoader - * @returns {Observable} + * @param {Object} cancelSignal + * @returns {Promise} */ function loadInitSegment( - customSegmentLoader : ILocalManifestInitSegmentLoader -) : Observable< ISegmentLoaderDataLoadedEvent> { - return new Observable((obs : Observer< - ISegmentLoaderDataLoadedEvent< ArrayBuffer | null > - >) => { + customSegmentLoader : ILocalManifestInitSegmentLoader, + cancelSignal : CancellationSignal +) : PPromise> { + return new PPromise((res, rej) => { + /** `true` when the custom segmentLoader should not be active anymore. */ let hasFinished = false; /** @@ -51,12 +53,15 @@ function loadInitSegment( size? : number; duration? : number; }) => { + if (hasFinished || cancelSignal.isCancelled) { + return; + } hasFinished = true; - obs.next({ type: "data-loaded", - value: { responseData: _args.data, + cancelSignal.deregister(abortLoader); + res({ resultType: "segment-loaded", + resultData: { responseData: _args.data, size: _args.size, duration: _args.duration } }); - obs.complete(); }; /** @@ -64,32 +69,47 @@ function loadInitSegment( * @param {*} err - The corresponding error encountered */ const reject = (err? : Error) => { + if (hasFinished || cancelSignal.isCancelled) { + return; + } hasFinished = true; - obs.error(err); + cancelSignal.deregister(abortLoader); + rej(err); }; const abort = customSegmentLoader({ resolve, reject }); - return () => { - if (!hasFinished && typeof abort === "function") { + cancelSignal.register(abortLoader); + /** + * The logic to run when this loader is cancelled while pending. + * @param {Error} err + */ + function abortLoader(err : CancellationError) { + if (hasFinished) { + return; + } + hasFinished = true; + if (typeof abort === "function") { abort(); } - }; + rej(err); + } }); } /** * @param {Object} segment * @param {Function} customSegmentLoader + * @param {Object} cancelSignal * @returns {Observable} */ function loadSegment( segment : { time : number; duration : number; timestampOffset? : number }, - customSegmentLoader : ILocalManifestSegmentLoader -) : Observable< ISegmentLoaderDataLoadedEvent> { - return new Observable((obs : Observer< - ISegmentLoaderDataLoadedEvent< ArrayBuffer | null > - >) => { + customSegmentLoader : ILocalManifestSegmentLoader, + cancelSignal : CancellationSignal +) : PPromise< ISegmentLoaderResultSegmentLoaded> { + return new PPromise((res, rej) => { + /** `true` when the custom segmentLoader should not be active anymore. */ let hasFinished = false; /** @@ -101,12 +121,15 @@ function loadSegment( size? : number; duration? : number; }) => { + if (hasFinished || cancelSignal.isCancelled) { + return; + } hasFinished = true; - obs.next({ type: "data-loaded", - value: { responseData: _args.data, + cancelSignal.deregister(abortLoader); + res({ resultType: "segment-loaded", + resultData: { responseData: _args.data, size: _args.size, duration: _args.duration } }); - obs.complete(); }; /** @@ -114,7 +137,11 @@ function loadSegment( * @param {*} err - The corresponding error encountered */ const reject = (err? : Error) => { + if (hasFinished || cancelSignal.isCancelled) { + return; + } hasFinished = true; + cancelSignal.deregister(abortLoader); // Format error and send it const castedErr = err as (null | undefined | { message? : string; @@ -128,39 +155,58 @@ function loadSegment( castedErr?.canRetry ?? false, castedErr?.isOfflineError ?? false, castedErr?.xhr); - obs.error(emittedErr); + rej(emittedErr); }; const abort = customSegmentLoader(segment, { resolve, reject }); - return () => { - if (!hasFinished && typeof abort === "function") { + cancelSignal.register(abortLoader); + /** + * The logic to run when this loader is cancelled while pending. + * @param {Error} err + */ + function abortLoader(err : CancellationError) { + if (hasFinished) { + return; + } + hasFinished = true; + if (typeof abort === "function") { abort(); } - }; + rej(err); + } }); } /** * Generic segment loader for the local Manifest. - * @param {Object} arg - * @returns {Observable} + * @param {string | null} _url + * @param {Object} content + * @param {Object} cancelSignal + * @param {Object} _callbacks + * @returns {Promise} */ export default function segmentLoader( - { segment } : ISegmentLoaderArguments -) : Observable< ISegmentLoaderEvent< ArrayBuffer | Uint8Array | null > > { + _url : string | null, + content : ISegmentContext, + cancelSignal : CancellationSignal, + _callbacks : ISegmentLoaderCallbacks +) : PPromise> { + const { segment } = content; const privateInfos = segment.privateInfos; if (segment.isInit) { if (privateInfos === undefined || isNullOrUndefined(privateInfos.localManifestInitSegment)) { throw new Error("Segment is not a local Manifest segment"); } - return loadInitSegment(privateInfos.localManifestInitSegment.load); + return loadInitSegment(privateInfos.localManifestInitSegment.load, + cancelSignal); } if (privateInfos === undefined || isNullOrUndefined(privateInfos.localManifestSegment)) { throw new Error("Segment is not an local Manifest segment"); } return loadSegment(privateInfos.localManifestSegment.segment, - privateInfos.localManifestSegment.load); + privateInfos.localManifestSegment.load, + cancelSignal); } diff --git a/src/transports/local/segment_parser.ts b/src/transports/local/segment_parser.ts index 1aff88139e..56de684b08 100644 --- a/src/transports/local/segment_parser.ts +++ b/src/transports/local/segment_parser.ts @@ -21,23 +21,23 @@ import { import { getTimeCodeScale } from "../../parsers/containers/matroska"; import takeFirstSet from "../../utils/take_first_set"; import { - ISegmentParserParsedSegment, + ISegmentContext, ISegmentParserParsedInitSegment, - ISegmentParserArguments, + ISegmentParserParsedSegment, } from "../types"; import getISOBMFFTimingInfos from "../utils/get_isobmff_timing_infos"; import inferSegmentContainer from "../utils/infer_segment_container"; -export default function segmentParser({ - content, - response, - initTimescale, -} : ISegmentParserArguments +export default function segmentParser( + loadedSegment : { data : ArrayBuffer | Uint8Array | null; + isChunked : boolean; }, + content : ISegmentContext, + initTimescale : number | undefined ) : ISegmentParserParsedInitSegment | ISegmentParserParsedSegment { const { period, adaptation, representation, segment } = content; - const { data } = response; + const { data } = loadedSegment; const appendWindow : [ number, number | undefined ] = [ period.start, period.end ]; if (data === null) { diff --git a/src/transports/local/text_parser.ts b/src/transports/local/text_parser.ts index 2c86e2b61a..3ceadfc1dc 100644 --- a/src/transports/local/text_parser.ts +++ b/src/transports/local/text_parser.ts @@ -14,12 +14,6 @@ * limitations under the License. */ -import Manifest, { - Adaptation, - ISegment, - Period, - Representation, -} from "../../manifest"; import { getMDHDTimescale } from "../../parsers/containers/isobmff"; import { strToUtf8, @@ -27,7 +21,8 @@ import { } from "../../utils/string_parsing"; import takeFirstSet from "../../utils/take_first_set"; import { - ISegmentParserArguments, + ILoadedTextSegmentFormat, + ISegmentContext, ISegmentParserParsedInitSegment, ISegmentParserParsedSegment, ITextTrackSegmentData, @@ -41,7 +36,6 @@ import { /** * Parse TextTrack data when it is embedded in an ISOBMFF file. - * * @param {ArrayBuffer|Uint8Array|string} data - The segment data. * @param {boolean} isChunked - If `true`, the `data` may contain only a * decodable subpart of the full data in the linked segment. @@ -53,18 +47,13 @@ import { * to that segment if no new timescale is defined in it. * Can be `undefined` if no timescale was defined, if it is not known, or if * no linked initialization segment was yet parsed. - * @returns {Observable.} + * @returns {Object} */ function parseISOBMFFEmbeddedTextTrack( - data : Uint8Array | ArrayBuffer | string, + data : string | Uint8Array | ArrayBuffer, isChunked : boolean, - content : { manifest : Manifest; - period : Period; - adaptation : Adaptation; - representation : Representation; - segment : ISegment; }, - initTimescale : number | undefined, - __priv_patchLastSegmentInSidx? : boolean + content : ISegmentContext, + initTimescale : number | undefined ) : ISegmentParserParsedInitSegment | ISegmentParserParsedSegment { @@ -99,23 +88,18 @@ function parseISOBMFFEmbeddedTextTrack( /** * Parse TextTrack data when it is in plain text form. - * * @param {ArrayBuffer|Uint8Array|string} data - The segment data. * @param {boolean} isChunked - If `true`, the `data` may contain only a * decodable subpart of the full data in the linked segment. * @param {Object} content - Object describing the context of the given * segment's data: of which segment, `Representation`, `Adaptation`, `Period`, * `Manifest` it is a part of etc. - * @returns {Observable.} + * @returns {Object} */ function parsePlainTextTrack( - data : Uint8Array | ArrayBuffer | string, + data : string | Uint8Array | ArrayBuffer, isChunked : boolean, - content : { manifest : Manifest; - period : Period; - adaptation : Adaptation; - representation : Representation; - segment : ISegment; } + content : ISegmentContext ) : ISegmentParserParsedInitSegment | ISegmentParserParsedSegment { @@ -147,21 +131,21 @@ function parsePlainTextTrack( /** * Parse TextTrack data. - * @param {Object} infos - * @returns {Observable.} + * @param {Object} loadedSegment + * @param {Object} content + * @param {number | undefined} initTimescale + * @returns {Object} */ export default function textTrackParser( - { response, - content, - initTimescale } : ISegmentParserArguments< Uint8Array | - ArrayBuffer | - string | - null > + loadedSegment : { data : ILoadedTextSegmentFormat; + isChunked : boolean; }, + content : ISegmentContext, + initTimescale : number | undefined ) : ISegmentParserParsedInitSegment | ISegmentParserParsedSegment { const { period, adaptation, representation, segment } = content; - const { data, isChunked } = response; + const { data, isChunked } = loadedSegment; if (data === null) { // No data, just return an empty placeholder object diff --git a/src/transports/metaplaylist/manifest_loader.ts b/src/transports/metaplaylist/manifest_loader.ts index 26a6ce3ccf..096b1b79f1 100644 --- a/src/transports/metaplaylist/manifest_loader.ts +++ b/src/transports/metaplaylist/manifest_loader.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import { Observable } from "rxjs"; import request from "../../utils/request"; +import { CancellationSignal } from "../../utils/task_canceller"; import { CustomManifestLoader, - IManifestLoaderArguments, - IManifestLoaderEvent, + ILoadedManifestFormat, + IRequestedData, } from "../types"; import callCustomManifestLoader from "../utils/call_custom_manifest_loader"; @@ -28,12 +28,13 @@ import callCustomManifestLoader from "../utils/call_custom_manifest_loader"; * @param {string} url */ function regularManifestLoader( - { url } : IManifestLoaderArguments -) : Observable< IManifestLoaderEvent > { + url : string | undefined, + cancelSignal : CancellationSignal +) : Promise< IRequestedData > { if (url === undefined) { throw new Error("Cannot perform HTTP(s) request. URL not known"); } - return request({ url, responseType: "text" }); + return request({ url, responseType: "text", cancelSignal }); } /** @@ -42,12 +43,13 @@ function regularManifestLoader( * @returns {Function} */ export default function generateManifestLoader( - options: { customManifestLoader?: CustomManifestLoader } -) : (args : IManifestLoaderArguments) => Observable< IManifestLoaderEvent > { - const { customManifestLoader } = options; - if (typeof customManifestLoader !== "function") { - return regularManifestLoader; - } - return callCustomManifestLoader(customManifestLoader, - regularManifestLoader); + { customManifestLoader } : { customManifestLoader?: CustomManifestLoader } +) : ( + url : string | undefined, + cancelSignal : CancellationSignal + ) => Promise> +{ + return typeof customManifestLoader !== "function" ? + regularManifestLoader : + callCustomManifestLoader(customManifestLoader, regularManifestLoader); } diff --git a/src/transports/metaplaylist/pipelines.ts b/src/transports/metaplaylist/pipelines.ts index 5593db52ec..13c556c737 100644 --- a/src/transports/metaplaylist/pipelines.ts +++ b/src/transports/metaplaylist/pipelines.ts @@ -14,18 +14,7 @@ * limitations under the License. */ -import { - combineLatest, - merge as observableMerge, - Observable, - of as observableOf, -} from "rxjs"; -import { - filter, - map, - mergeMap, - share, -} from "rxjs/operators"; +import PPromise from "pinkie"; import features from "../../features"; import Manifest, { Adaptation, @@ -38,18 +27,24 @@ import parseMetaPlaylist, { IParserResponse as IMPLParserResponse, } from "../../parsers/manifest/metaplaylist"; import { IParsedManifest } from "../../parsers/manifest/types"; -import deferSubscriptions from "../../utils/defer_subscriptions"; import isNullOrUndefined from "../../utils/is_null_or_undefined"; import objectAssign from "../../utils/object_assign"; +import { CancellationSignal } from "../../utils/task_canceller"; import { IChunkTimeInfo, IImageTrackSegmentData, - ILoaderDataLoadedValue, - IManifestParserArguments, - IManifestParserResponseEvent, - IManifestParserWarningEvent, - ISegmentLoaderArguments, - ISegmentParserArguments, + ILoadedAudioVideoSegmentFormat, + ILoadedImageSegmentFormat, + ILoadedTextSegmentFormat, + IManifestParserOptions, + IManifestParserRequestScheduler, + IManifestParserResult, + IRequestedData, + ISegmentContext, + ISegmentLoaderCallbacks, + ISegmentLoaderResultChunkedComplete, + ISegmentLoaderResultSegmentCreated, + ISegmentLoaderResultSegmentLoaded, ISegmentParserParsedInitSegment, ISegmentParserParsedSegment, ITextTrackSegmentData, @@ -85,36 +80,6 @@ function getOriginalContent(segment : ISegment) : { manifest : Manifest; segment: originalSegment }; } -/** - * Prepare any wrapped segment loader's arguments. - * @param {Object} segment - * @param {number} offset - * @returns {Object} - */ -function getLoaderArguments( - segment : ISegment, - url : string | null -) : ISegmentLoaderArguments { - const content = getOriginalContent(segment); - return objectAssign({ url }, content); -} - -/** - * Prepare any wrapped segment parser's arguments. - * @param {Object} arguments - * @param {Object} segment - * @param {number} offset - * @returns {Object} - */ -function getParserArguments( - { initTimescale, response } : ISegmentParserArguments, - segment : ISegment -) : ISegmentParserArguments { - return { initTimescale, - response, - content: getOriginalContent(segment) }; -} - /** * @param {Object} transports * @param {string} transportName @@ -168,73 +133,54 @@ export default function(options : ITransportOptions): ITransportPipelines { supplementaryImageTracks: [] }); const manifestPipeline = { - loader: manifestLoader, - parser( - { response, - url: loaderURL, - previousManifest, - scheduleRequest, - unsafeMode, - externalClockOffset } : IManifestParserArguments - ) : Observable { - const url = response.url === undefined ? loaderURL : - response.url; - const { responseData } = response; - - const parserOptions = { url, - serverSyncInfos: options.serverSyncInfos }; - - return handleParsedResult(parseMetaPlaylist(responseData, parserOptions)); + loadManifest: manifestLoader, + + parseManifest( + manifestData : IRequestedData, + parserOptions : IManifestParserOptions, + onWarnings : (warnings: Error[]) => void, + cancelSignal : CancellationSignal, + scheduleRequest : IManifestParserRequestScheduler + ) : Promise { + const url = manifestData.url ?? parserOptions.originalUrl; + const { responseData } = manifestData; + + const mplParserOptions = { url, + serverSyncInfos: options.serverSyncInfos }; + const parsed = parseMetaPlaylist(responseData, mplParserOptions); + + return handleParsedResult(parsed); function handleParsedResult( parsedResult : IMPLParserResponse - ) : Observable { + ) : Promise { if (parsedResult.type === "done") { const manifest = new Manifest(parsedResult.value, options); - return observableOf({ type: "parsed", - value: { manifest } }); + return PPromise.resolve({ manifest }); } - const loaders$ = parsedResult.value.ressources.map((ressource) => { + const parsedValue = parsedResult.value; + const loaderProms = parsedValue.ressources.map((resource) => { const transport = getTransportPipelines(transports, - ressource.transportType, + resource.transportType, otherTransportOptions); - const request$ = scheduleRequest(() => - transport.manifest.loader({ url : ressource.url }).pipe( - filter( - (e): e is { type : "data-loaded"; - value : ILoaderDataLoadedValue; } => - e.type === "data-loaded" - ), - map((e) : ILoaderDataLoadedValue< Document | string > => e.value) - )); - - return request$.pipe(mergeMap((responseValue) => { - return transport.manifest.parser({ response: responseValue, - url: ressource.url, - scheduleRequest, - previousManifest, - unsafeMode, - externalClockOffset }); - })).pipe(deferSubscriptions(), share()); + return scheduleRequest(loadSubManifest) + .then((data) => + transport.manifest.parseManifest(data, + { ...parserOptions, + originalUrl: resource.url }, + onWarnings, + cancelSignal, + scheduleRequest)); + function loadSubManifest() { + return transport.manifest.loadManifest(resource.url, cancelSignal); + } }); - const warnings$ : Array> = - loaders$.map(loader => - loader.pipe(filter((evt) : evt is IManifestParserWarningEvent => - evt.type === "warning"))); - - const responses$ : Array> = - loaders$.map(loader => - loader.pipe(filter((evt) : evt is IManifestParserResponseEvent => - evt.type === "parsed"))); - - return observableMerge( - combineLatest(responses$).pipe(mergeMap((evt) => { - const loadedRessources = evt.map(e => e.value.manifest); - return handleParsedResult(parsedResult.value.continue(loadedRessources)); - })), - ...warnings$); + return PPromise.all(loaderProms).then(parsedReqs => { + const loadedRessources = parsedReqs.map(e => e.manifest); + return handleParsedResult(parsedResult.value.continue(loadedRessources)); + }); } }, }; @@ -298,21 +244,34 @@ export default function(options : ITransportOptions): ITransportPipelines { } const audioPipeline = { - loader({ segment, url } : ISegmentLoaderArguments) { + loadSegment( + url : string | null, + content : ISegmentContext, + cancelToken : CancellationSignal, + callbacks : ISegmentLoaderCallbacks + ) : Promise | + ISegmentLoaderResultSegmentCreated | + ISegmentLoaderResultChunkedComplete> + { + const { segment } = content; const { audio } = getTransportPipelinesFromSegment(segment); - return audio.loader(getLoaderArguments(segment, url)); + const ogContent = getOriginalContent(segment); + return audio.loadSegment(url, ogContent, cancelToken, callbacks); }, - parser( - args : ISegmentParserArguments - ) : ISegmentParserParsedSegment | - ISegmentParserParsedInitSegment + parseSegment( + loadedSegment : { data : ILoadedAudioVideoSegmentFormat; isChunked : boolean }, + content : ISegmentContext, + initTimescale : number | undefined + ) : ISegmentParserParsedInitSegment | + ISegmentParserParsedSegment { - const { content } = args; const { segment } = content; const { contentStart, contentEnd } = getMetaPlaylistPrivateInfos(segment); const { audio } = getTransportPipelinesFromSegment(segment); - const parsed = audio.parser(getParserArguments(args, segment)); + const ogContent = getOriginalContent(segment); + + const parsed = audio.parseSegment(loadedSegment, ogContent, initTimescale); if (parsed.segmentType === "init") { return parsed; } @@ -322,21 +281,34 @@ export default function(options : ITransportOptions): ITransportPipelines { }; const videoPipeline = { - loader({ segment, url } : ISegmentLoaderArguments) { + loadSegment( + url : string | null, + content : ISegmentContext, + cancelToken : CancellationSignal, + callbacks : ISegmentLoaderCallbacks + ) : Promise | + ISegmentLoaderResultSegmentCreated | + ISegmentLoaderResultChunkedComplete> + { + const { segment } = content; const { video } = getTransportPipelinesFromSegment(segment); - return video.loader(getLoaderArguments(segment, url)); + const ogContent = getOriginalContent(segment); + return video.loadSegment(url, ogContent, cancelToken, callbacks); }, - parser( - args : ISegmentParserArguments - ) : ISegmentParserParsedSegment | - ISegmentParserParsedInitSegment + parseSegment( + loadedSegment : { data : ILoadedAudioVideoSegmentFormat; isChunked : boolean }, + content : ISegmentContext, + initTimescale : number | undefined + ) : ISegmentParserParsedInitSegment | + ISegmentParserParsedSegment { - const { content } = args; const { segment } = content; const { contentStart, contentEnd } = getMetaPlaylistPrivateInfos(segment); const { video } = getTransportPipelinesFromSegment(segment); - const parsed = video.parser(getParserArguments(args, segment)); + const ogContent = getOriginalContent(segment); + + const parsed = video.parseSegment(loadedSegment, ogContent, initTimescale); if (parsed.segmentType === "init") { return parsed; } @@ -346,21 +318,34 @@ export default function(options : ITransportOptions): ITransportPipelines { }; const textTrackPipeline = { - loader({ segment, url } : ISegmentLoaderArguments) { + loadSegment( + url : string | null, + content : ISegmentContext, + cancelToken : CancellationSignal, + callbacks : ISegmentLoaderCallbacks + ) : Promise | + ISegmentLoaderResultSegmentCreated | + ISegmentLoaderResultChunkedComplete> + { + const { segment } = content; const { text } = getTransportPipelinesFromSegment(segment); - return text.loader(getLoaderArguments(segment, url)); + const ogContent = getOriginalContent(segment); + return text.loadSegment(url, ogContent, cancelToken, callbacks); }, - parser( - args: ISegmentParserArguments< ArrayBuffer | string | Uint8Array | null> + parseSegment( + loadedSegment : { data : ILoadedTextSegmentFormat; isChunked : boolean }, + content : ISegmentContext, + initTimescale : number | undefined ) : ISegmentParserParsedInitSegment | - ISegmentParserParsedSegment + ISegmentParserParsedSegment { - const { content } = args; const { segment } = content; const { contentStart, contentEnd } = getMetaPlaylistPrivateInfos(segment); const { text } = getTransportPipelinesFromSegment(segment); - const parsed = text.parser(getParserArguments(args, segment)); + const ogContent = getOriginalContent(segment); + + const parsed = text.parseSegment(loadedSegment, ogContent, initTimescale); if (parsed.segmentType === "init") { return parsed; } @@ -370,22 +355,34 @@ export default function(options : ITransportOptions): ITransportPipelines { }; const imageTrackPipeline = { - loader({ segment, url } : ISegmentLoaderArguments) { + loadSegment( + url : string | null, + content : ISegmentContext, + cancelToken : CancellationSignal, + callbacks : ISegmentLoaderCallbacks + ) : Promise | + ISegmentLoaderResultSegmentCreated | + ISegmentLoaderResultChunkedComplete> + { + const { segment } = content; const { image } = getTransportPipelinesFromSegment(segment); - return image.loader(getLoaderArguments(segment, url)); + const ogContent = getOriginalContent(segment); + return image.loadSegment(url, ogContent, cancelToken, callbacks); }, - parser( - args : ISegmentParserArguments - ) : ISegmentParserParsedInitSegment | - ISegmentParserParsedSegment + parseSegment( + loadedSegment : { data : ILoadedImageSegmentFormat; isChunked : boolean }, + content : ISegmentContext, + initTimescale : number | undefined + ) : ISegmentParserParsedInitSegment | + ISegmentParserParsedSegment { - const { content } = args; const { segment } = content; const { contentStart, contentEnd } = getMetaPlaylistPrivateInfos(segment); const { image } = getTransportPipelinesFromSegment(segment); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - const parsed = image.parser(getParserArguments(args, segment)); + const ogContent = getOriginalContent(segment); + + const parsed = image.parseSegment(loadedSegment, ogContent, initTimescale); if (parsed.segmentType === "init") { return parsed; } diff --git a/src/transports/smooth/pipelines.ts b/src/transports/smooth/pipelines.ts index 958b024208..d7a83ee330 100644 --- a/src/transports/smooth/pipelines.ts +++ b/src/transports/smooth/pipelines.ts @@ -14,19 +14,12 @@ * limitations under the License. */ -import { - Observable, - of as observableOf, -} from "rxjs"; -import { - map, tap, -} from "rxjs/operators"; +import PPromise from "pinkie"; import features from "../../features"; import log from "../../log"; import Manifest, { Adaptation, ISegment, - Representation, } from "../../manifest"; import { getMDAT } from "../../parsers/containers/isobmff"; import createSmoothManifestParser, { @@ -37,17 +30,21 @@ import { strToUtf8, utf8ToStr, } from "../../utils/string_parsing"; +import { CancellationSignal } from "../../utils/task_canceller"; import warnOnce from "../../utils/warn_once"; import { IChunkTimeInfo, IImageTrackSegmentData, - IManifestLoaderArguments, - IManifestParserArguments, - IManifestParserResponseEvent, - IManifestParserWarningEvent, - ISegmentLoaderArguments, - ISegmentLoaderEvent, - ISegmentParserArguments, + ILoadedAudioVideoSegmentFormat, + ILoadedImageSegmentFormat, + ILoadedTextSegmentFormat, + IManifestParserOptions, + IManifestParserResult, + IRequestedData, + ISegmentContext, + ISegmentLoaderCallbacks, + ISegmentLoaderResultSegmentCreated, + ISegmentLoaderResultSegmentLoaded, ISegmentParserParsedInitSegment, ISegmentParserParsedSegment, ITextTrackSegmentData, @@ -56,7 +53,6 @@ import { } from "../types"; import checkISOBMFFIntegrity from "../utils/check_isobmff_integrity"; import generateManifestLoader from "../utils/generate_manifest_loader"; -import returnParsedManifest from "../utils/return_parsed_manifest"; import extractTimingsInfos, { INextSegmentsInfos, } from "./extract_timings_infos"; @@ -65,6 +61,7 @@ import generateSegmentLoader from "./segment_loader"; import { extractISML, extractToken, + isMP4EmbeddedTrack, replaceToken, resolveManifest, } from "./utils"; @@ -98,93 +95,105 @@ function addNextSegments( export default function(options : ITransportOptions) : ITransportPipelines { const smoothManifestParser = createSmoothManifestParser(options); - const segmentLoader = generateSegmentLoader(options.segmentLoader); + const segmentLoader = generateSegmentLoader(options); const manifestLoaderOptions = { customManifestLoader: options.manifestLoader }; const manifestLoader = generateManifestLoader(manifestLoaderOptions, "text"); const manifestPipeline = { - resolver( - { url } : IManifestLoaderArguments - ) : Observable { + // TODO (v4.x.x) Remove that function + resolveManifestUrl( + url : string | undefined, + cancelSignal : CancellationSignal + ) : Promise { if (url === undefined) { - return observableOf({ url : undefined }); + return PPromise.resolve(undefined); } - // TODO Remove WSX logic - let resolving; + let resolving : Promise; if (WSX_REG.test(url)) { warnOnce("Giving WSX URL to loadVideo is deprecated." + - " You should only give Manifest URLs."); + " You should only give Manifest URLs."); resolving = request({ url: replaceToken(url, ""), - responseType: "document" }) - .pipe(map(({ value }) : string => { + responseType: "document", + cancelSignal }) + .then(value => { const extractedURL = extractISML(value.responseData); if (extractedURL === null || extractedURL.length === 0) { throw new Error("Invalid ISML"); } return extractedURL; - })); + }); } else { - resolving = observableOf(url); + resolving = PPromise.resolve(url); } const token = extractToken(url); - return resolving.pipe(map((_url) => ({ - url: replaceToken(resolveManifest(_url), token), - }))); + return resolving.then((_url) => + replaceToken(resolveManifest(_url), token)); }, - loader: manifestLoader, - - parser( - { response, url: reqURL } : IManifestParserArguments - ) : Observable { - const url = response.url === undefined ? reqURL : - response.url; - const data = typeof response.responseData === "string" ? - new DOMParser().parseFromString(response.responseData, "text/xml") : - response.responseData as Document; // TODO find a way to check if Document? - const { receivedTime: manifestReceivedTime } = response; - const parserResult = smoothManifestParser(data, url, manifestReceivedTime); + loadManifest: manifestLoader, + + parseManifest( + manifestData : IRequestedData, + parserOptions : IManifestParserOptions + ) : IManifestParserResult { + const url = manifestData.url ?? parserOptions.originalUrl; + const { receivedTime: manifestReceivedTime, responseData } = manifestData; + + const documentData = typeof responseData === "string" ? + new DOMParser().parseFromString(responseData, "text/xml") : + responseData as Document; // TODO find a way to check if Document? + + const parserResult = smoothManifestParser(documentData, + url, + manifestReceivedTime); + const manifest = new Manifest(parserResult, { representationFilter: options.representationFilter, supplementaryImageTracks: options.supplementaryImageTracks, supplementaryTextTracks: options.supplementaryTextTracks, }); - return returnParsedManifest(manifest, url); + return { manifest, url }; }, }; - const segmentPipeline = { - loader( - content : ISegmentLoaderArguments - ) : Observable< ISegmentLoaderEvent< ArrayBuffer|Uint8Array|null> > { - if (content.segment.isInit || options.checkMediaSegmentIntegrity !== true) { - return segmentLoader(content); - } - return segmentLoader(content).pipe(tap(res => { - if ((res.type === "data-loaded" || res.type === "data-chunk") && - res.value.responseData !== null) - { - checkISOBMFFIntegrity(new Uint8Array(res.value.responseData), - content.segment.isInit); - } - })); + /** + * Export functions allowing to load and parse audio and video smooth + * segments. + */ + const audioVideoPipeline = { + /** + * Load a Smooth audio/video segment. + * @param {string|null} url + * @param {Object} content + * @param {Object} cancelSignal + * @param {Object} callbacks + * @returns {Promise} + */ + loadSegment( + url : string | null, + content : ISegmentContext, + cancelSignal : CancellationSignal, + callbacks : ISegmentLoaderCallbacks + ) : PPromise | + ISegmentLoaderResultSegmentCreated> + { + return segmentLoader(url, content, cancelSignal, callbacks); }, - parser({ - content, - response, - initTimescale, - } : ISegmentParserArguments< ArrayBuffer | Uint8Array | null > - ) : ISegmentParserParsedInitSegment | - ISegmentParserParsedSegment + parseSegment( + loadedSegment : { data : ArrayBuffer | Uint8Array | null; + isChunked : boolean; }, + content : ISegmentContext, + initTimescale : number | undefined + ) : ISegmentParserParsedInitSegment< ArrayBuffer | Uint8Array | null> | + ISegmentParserParsedSegment< ArrayBuffer | Uint8Array | null > { const { segment, adaptation, manifest } = content; - const { data, isChunked } = response; + const { data, isChunked } = loadedSegment; if (data === null) { if (segment.isInit) { return { segmentType: "init", @@ -241,45 +250,58 @@ export default function(options : ITransportOptions) : ITransportPipelines { }; const textTrackPipeline = { - loader( - { segment, - representation, - url } : ISegmentLoaderArguments - ) : Observable< ISegmentLoaderEvent > { + loadSegment( + url : string | null, + content : ISegmentContext, + cancelSignal : CancellationSignal, + callbacks : ISegmentLoaderCallbacks + ) : PPromise | + ISegmentLoaderResultSegmentCreated> { + const { segment, representation } = content; if (segment.isInit || url === null) { - return observableOf({ type: "data-created" as const, - value: { responseData: null } }); + return PPromise.resolve({ resultType: "segment-created", + resultData: null }); } + const isMP4 = isMP4EmbeddedTrack(representation); - if (!isMP4 || options.checkMediaSegmentIntegrity !== true) { + if (!isMP4) { + return request({ url, + responseType: "text", + cancelSignal, + onProgress: callbacks.onProgress }) + .then((data) => ({ resultType: "segment-loaded" as const, + resultData: data })); + } else { return request({ url, - responseType: isMP4 ? "arraybuffer" : "text", - sendProgressEvents: true }); + responseType: "arraybuffer", + cancelSignal, + onProgress: callbacks.onProgress }) + .then((data) => { + if (options.checkMediaSegmentIntegrity !== true) { + return { resultType: "segment-loaded" as const, + resultData: data }; + } + const dataU8 = new Uint8Array(data.responseData); + checkISOBMFFIntegrity(dataU8, content.segment.isInit); + return { resultType: "segment-loaded" as const, + resultData: { ...data, responseData: dataU8 } }; + }); } - return request({ url, - responseType: "arraybuffer", - sendProgressEvents: true }) - .pipe(tap(res => { - if (res.type === "data-loaded") { - checkISOBMFFIntegrity(new Uint8Array(res.value.responseData), - segment.isInit); - } - })); }, - parser({ - content, - response, - initTimescale, - } : ISegmentParserArguments - ) : ISegmentParserParsedInitSegment | - ISegmentParserParsedSegment + parseSegment( + loadedSegment : { data : ArrayBuffer | Uint8Array | string | null; + isChunked : boolean; }, + content : ISegmentContext, + initTimescale : number | undefined + ) : ISegmentParserParsedInitSegment< null > | + ISegmentParserParsedSegment< ITextTrackSegmentData | null > { const { manifest, adaptation, representation, segment } = content; const { language } = adaptation; const isMP4 = isMP4EmbeddedTrack(representation); const { mimeType = "", codec = "" } = representation; - const { data, isChunked } = response; + const { data, isChunked } = loadedSegment; if (segment.isInit) { // text init segment has no use in HSS return { segmentType: "init", initializationData: null, @@ -409,27 +431,36 @@ export default function(options : ITransportOptions) : ITransportPipelines { }; const imageTrackPipeline = { - loader( - { segment, - url } : ISegmentLoaderArguments - ) : Observable< ISegmentLoaderEvent > { - if (segment.isInit || url === null) { + loadSegment( + url : string | null, + content : ISegmentContext, + cancelSignal : CancellationSignal, + callbacks : ISegmentLoaderCallbacks + ) : PPromise | + ISegmentLoaderResultSegmentCreated> { + if (content.segment.isInit || url === null) { // image do not need an init segment. Passthrough directly to the parser - return observableOf({ type: "data-created" as const, - value: { responseData: null } }); + return PPromise.resolve({ resultType: "segment-created" as const, + resultData: null }); } return request({ url, responseType: "arraybuffer", - sendProgressEvents: true }); + onProgress: callbacks.onProgress, + cancelSignal }) + .then((data) => ({ resultType: "segment-loaded" as const, + resultData: data })); }, - parser( - { response, content } : ISegmentParserArguments - ) : ISegmentParserParsedInitSegment | - ISegmentParserParsedSegment + parseSegment( + loadedSegment : { data : ArrayBuffer | Uint8Array | null; + isChunked : boolean; }, + content : ISegmentContext, + _initTimescale : number | undefined + ) : ISegmentParserParsedInitSegment< null > | + ISegmentParserParsedSegment< IImageTrackSegmentData | null > { - const { data, isChunked } = response; + const { data, isChunked } = loadedSegment; if (content.segment.isInit) { // image init segment has no use return { segmentType: "init", @@ -464,24 +495,13 @@ export default function(options : ITransportOptions) : ITransportPipelines { duration: Number.MAX_VALUE }, chunkOffset: 0, protectionDataUpdate: false, - appendWindow: [undefined, undefined] } ; + appendWindow: [undefined, undefined] }; }, }; return { manifest: manifestPipeline, - audio: segmentPipeline, - video: segmentPipeline, + audio: audioVideoPipeline, + video: audioVideoPipeline, text: textTrackPipeline, image: imageTrackPipeline }; } - -/** - * Returns true if the given texttrack segment represents a textrack embedded - * in a mp4 file. - * @param {Representation} representation - * @returns {Boolean} - */ -function isMP4EmbeddedTrack(representation : Representation) : boolean { - return typeof representation.mimeType === "string" && - representation.mimeType.indexOf("mp4") >= 0; -} diff --git a/src/transports/smooth/segment_loader.ts b/src/transports/smooth/segment_loader.ts index 1fb9bd5669..af12dc31bf 100644 --- a/src/transports/smooth/segment_loader.ts +++ b/src/transports/smooth/segment_loader.ts @@ -14,45 +14,47 @@ * limitations under the License. */ -import { - Observable, - Observer, - of as observableOf, -} from "rxjs"; +import PPromise from "pinkie"; import { CustomLoaderError } from "../../errors"; import assert from "../../utils/assert"; import request from "../../utils/request"; +import { + CancellationError, + CancellationSignal, +} from "../../utils/task_canceller"; import { CustomSegmentLoader, - ILoaderProgressEvent, - ISegmentLoaderArguments, - ISegmentLoaderDataLoadedEvent, - ISegmentLoaderEvent, + ISegmentContext, + ISegmentLoaderCallbacks, + ISegmentLoaderResultSegmentCreated, + ISegmentLoaderResultSegmentLoaded, } from "../types"; import byteRange from "../utils/byte_range"; +import checkISOBMFFIntegrity from "../utils/check_isobmff_integrity"; import { createAudioInitSegment, createVideoInitSegment, } from "./isobmff"; - -interface IRegularSegmentLoaderArguments extends ISegmentLoaderArguments { - url : string; -} - -type ICustomSegmentLoaderObserver = - Observer>; +import { isMP4EmbeddedTrack } from "./utils"; /** * Segment loader triggered if there was no custom-defined one in the API. - * @param {Object} opt - * @returns {Observable} + * @param {string} uri + * @param {Object} content + * @param {Object} callbacks + * @param {Object} cancelSignal + * @param {boolean} checkMediaSegmentIntegrity + * @returns {Promise} */ function regularSegmentLoader( - { url, segment } : IRegularSegmentLoaderArguments -) : Observable< ISegmentLoaderEvent > { + url : string, + content : ISegmentContext, + callbacks : ISegmentLoaderCallbacks, + cancelSignal : CancellationSignal, + checkMediaSegmentIntegrity? : boolean +) : Promise> { let headers; - const range = segment.range; + const range = content.segment.range; if (Array.isArray(range)) { headers = { Range: byteRange(range) }; } @@ -60,24 +62,39 @@ function regularSegmentLoader( return request({ url, responseType: "arraybuffer", headers, - sendProgressEvents: true }); + cancelSignal, + onProgress: callbacks.onProgress }) + .then((data) => { + const isMP4 = isMP4EmbeddedTrack(content.representation); + if (!isMP4 || checkMediaSegmentIntegrity !== true) { + return { resultType: "segment-loaded" as const, + resultData: data }; + } + const dataU8 = new Uint8Array(data.responseData); + checkISOBMFFIntegrity(dataU8, content.segment.isInit); + return { resultType: "segment-loaded" as const, + resultData: { ...data, responseData: dataU8 } }; + }); } /** * Defines the url for the request, load the right loader (custom/default * one). */ -const generateSegmentLoader = ( - customSegmentLoader? : CustomSegmentLoader -) => ({ - segment, - representation, - adaptation, - period, - manifest, - url, -} : ISegmentLoaderArguments -) : Observable< ISegmentLoaderEvent > => { +const generateSegmentLoader = ({ + checkMediaSegmentIntegrity, + customSegmentLoader, +} : { + checkMediaSegmentIntegrity? : boolean; + customSegmentLoader? : CustomSegmentLoader; +}) => ( + url : string | null, + content : ISegmentContext, + cancelSignal : CancellationSignal, + callbacks : ISegmentLoaderCallbacks +) : Promise | + ISegmentLoaderResultSegmentCreated> => { + const { segment, manifest, period, adaptation, representation } = content; if (segment.isInit) { if (segment.privateInfos === undefined || segment.privateInfos.smoothInitSegment === undefined) @@ -126,11 +143,11 @@ const generateSegmentLoader = ( responseData = new Uint8Array(0); } - return observableOf({ type: "data-created" as const, - value: { responseData } }); + return PPromise.resolve({ resultType: "segment-created" as const, + resultData: responseData }); } else if (url === null) { - return observableOf({ type: "data-created" as const, - value: { responseData: null } }); + return PPromise.resolve({ resultType: "segment-created" as const, + resultData: null }); } else { const args = { adaptation, manifest, @@ -141,12 +158,17 @@ const generateSegmentLoader = ( url }; if (typeof customSegmentLoader !== "function") { - return regularSegmentLoader(args); + return regularSegmentLoader(url, + content, + callbacks, + cancelSignal, + checkMediaSegmentIntegrity); } - return new Observable((obs : ICustomSegmentLoaderObserver) => { + return new Promise((res, rej) => { + /** `true` when the custom segmentLoader should not be active anymore. */ let hasFinished = false; - let hasFallbacked = false; + /** * Callback triggered when the custom segment loader has a response. @@ -157,14 +179,27 @@ const generateSegmentLoader = ( size? : number; duration? : number; }) => { - if (!hasFallbacked) { - hasFinished = true; - obs.next({ type: "data-loaded", - value: { responseData: _args.data, + if (hasFinished || cancelSignal.isCancelled) { + return; + } + hasFinished = true; + cancelSignal.deregister(abortCustomLoader); + + const isMP4 = isMP4EmbeddedTrack(content.representation); + if (!isMP4 || checkMediaSegmentIntegrity !== true) { + res({ resultType: "segment-loaded" as const, + resultData: { responseData: _args.data, size: _args.size, duration: _args.duration } }); - obs.complete(); } + + const dataU8 = _args.data instanceof Uint8Array ? _args.data : + new Uint8Array(_args.data); + checkISOBMFFIntegrity(dataU8, content.segment.isInit); + res({ resultType: "segment-loaded" as const, + resultData: { responseData: dataU8, + size: _args.size, + duration: _args.duration } }); }; /** @@ -172,23 +207,25 @@ const generateSegmentLoader = ( * @param {*} err - The corresponding error encountered */ const reject = (err = {}) => { - if (!hasFallbacked) { - hasFinished = true; - - // Format error and send it - const castedErr = err as (null | undefined | { message? : string; - canRetry? : boolean; - isOfflineError? : boolean; - xhr? : XMLHttpRequest; }); - const message = castedErr?.message ?? - "Unknown error when fetching a Smooth segment through a " + - "custom segmentLoader."; - const emittedErr = new CustomLoaderError(message, - castedErr?.canRetry ?? false, - castedErr?.isOfflineError ?? false, - castedErr?.xhr); - obs.error(emittedErr); + if (hasFinished || cancelSignal.isCancelled) { + return; } + hasFinished = true; + cancelSignal.deregister(abortCustomLoader); + + // Format error and send it + const castedErr = err as (null | undefined | { message? : string; + canRetry? : boolean; + isOfflineError? : boolean; + xhr? : XMLHttpRequest; }); + const message = castedErr?.message ?? + "Unknown error when fetching a Smooth segment through a " + + "custom segmentLoader."; + const emittedErr = new CustomLoaderError(message, + castedErr?.canRetry ?? false, + castedErr?.isOfflineError ?? false, + castedErr?.xhr); + rej(emittedErr); }; const progress = ( @@ -196,33 +233,47 @@ const generateSegmentLoader = ( size : number; totalSize? : number; } ) => { - if (!hasFallbacked) { - obs.next({ type: "progress", value: { duration: _args.duration, - size: _args.size, - totalSize: _args.totalSize } }); + if (hasFinished || cancelSignal.isCancelled) { + return; } + callbacks.onProgress({ duration: _args.duration, + size: _args.size, + totalSize: _args.totalSize }); }; const fallback = () => { - hasFallbacked = true; - - // HACK What is TypeScript/RxJS doing here?????? - /* eslint-disable import/no-deprecated */ - /* eslint-disable @typescript-eslint/ban-ts-comment */ - // @ts-ignore - regularSegmentLoader(args).subscribe(obs); - /* eslint-enable import/no-deprecated */ - /* eslint-enable @typescript-eslint/ban-ts-comment */ + if (hasFinished || cancelSignal.isCancelled) { + return; + } + hasFinished = true; + cancelSignal.deregister(abortCustomLoader); + regularSegmentLoader(url, + content, + callbacks, + cancelSignal, + checkMediaSegmentIntegrity) + .then(res, rej); }; - const callbacks = { reject, resolve, fallback, progress }; - const abort = customSegmentLoader(args, callbacks); + const customCallbacks = { reject, resolve, fallback, progress }; + const abort = customSegmentLoader(args, customCallbacks); - return () => { - if (!hasFinished && !hasFallbacked && typeof abort === "function") { + cancelSignal.register(abortCustomLoader); + + /** + * The logic to run when the custom loader is cancelled while pending. + * @param {Error} err + */ + function abortCustomLoader(err : CancellationError) { + if (hasFinished) { + return; + } + hasFinished = true; + if (!hasFinished && typeof abort === "function") { abort(); } - }; + rej(err); + } }); } }; diff --git a/src/transports/smooth/utils.ts b/src/transports/smooth/utils.ts index 2f01c7e82a..2290640a3e 100644 --- a/src/transports/smooth/utils.ts +++ b/src/transports/smooth/utils.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Representation } from "../../manifest"; import isNonEmptyString from "../../utils/is_non_empty_string"; import warnOnce from "../../utils/warn_once"; @@ -73,9 +74,21 @@ function resolveManifest(url : string) : string { return url; } +/** + * Returns `true` if the given Representation refers to segments in an MP4 + * container + * @param {Representation} representation + * @returns {Boolean} + */ +function isMP4EmbeddedTrack(representation : Representation) : boolean { + return typeof representation.mimeType === "string" && + representation.mimeType.indexOf("mp4") >= 0; +} + export { extractISML, extractToken, + isMP4EmbeddedTrack, replaceToken, resolveManifest, }; diff --git a/src/transports/types.ts b/src/transports/types.ts index da9970cc4b..7c824e692e 100644 --- a/src/transports/types.ts +++ b/src/transports/types.ts @@ -14,9 +14,6 @@ * limitations under the License. */ -import { - Observable, -} from "rxjs"; import { IInbandEvent } from "../core/stream"; import Manifest, { Adaptation, @@ -30,6 +27,9 @@ import Manifest, { import { IBifThumbnail } from "../parsers/images/bif"; import { ILocalManifest } from "../parsers/manifest/local"; import { IMetaPlaylist } from "../parsers/manifest/metaplaylist"; +import TaskCanceller, { + CancellationSignal, +} from "../utils/task_canceller"; /** * Interface returned by any transport implementation. @@ -49,92 +49,215 @@ export interface ITransportPipelines { /** Functions allowing to load an parse the Manifest for this transport. */ manifest : ITransportManifestPipeline; /** Functions allowing to load an parse audio segments. */ - audio : ISegmentPipeline; /** Functions allowing to load an parse video segments. */ - video : ISegmentPipeline; /** Functions allowing to load an parse text (e.g. subtitles) segments. */ - text : ISegmentPipeline; /** Functions allowing to load an parse image (e.g. thumbnails) segments. */ - image : ISegmentPipeline; } /** Functions allowing to load and parse the Manifest. */ -export interface ITransportManifestPipeline { resolver? : IManifestResolverFunction; - loader : IManifestLoaderFunction; - parser : IManifestParserFunction; } - -/** - * @deprecated - * "Resolves the Manifest's URL, to obtain its true URL. - * This is a deprecated function which corresponds to an old use case at - * Canal+ where the URL of the Manifest first need to be parsed from a .wsx - * file. - * Thankfully this API should not be used anymore, though to not break - * compatibility, we have to keep it until a v4.x.x release. - * - * @param {Object} x - Object containing the URL used to obtain the real URL of - * the Manifest. - * @returns {Observable.} - */ -export type IManifestResolverFunction = - (x : IManifestLoaderArguments) => Observable; +export interface ITransportManifestPipeline { + /** + * "Loader" of the Manifest pipeline, allowing to request a Manifest so it can + * later be parsed by the `parseManifest` function. + * + * @param {string|undefined} url - URL of the Manifest we want to load. + * `undefined` if the Manifest doesn't have an URL linked to it, in which case + * the Manifest should be loaded through another mean. + * @param {CancellationSignal} cancellationSignal - Signal which will allow to + * cancel the loading operation if the 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 Manifest, that + * then can be parsed through the `parseManifest` 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. + */ + loadManifest : ( + url : string | undefined, + cancelSignal : CancellationSignal, + ) => Promise>; -/** - * "Loader" of the Manifest pipeline, allowing to request a Manifest so it can - * later be parsed by the `parseManifest` function. - * - * @param {Object} x - Object containing the URL of the Manifest we want to - * load. - * @returns {Observable.} - */ -export type IManifestLoaderFunction = - (x : IManifestLoaderArguments) => Observable; + /** + * "Parser" of the Manifest pipeline, allowing to parse a loaded Manifest so + * it can be exploited by the rest of the RxPlayer's logic. + * + * @param {Object} manifestData - Response obtained from the `loadManifest` + * function. + * @param {Object} parserOptions - Various options relative to the parsing + * operation. + * @param {Function} onWarnings - Callbacks called: + * - when minor Manifest parsing errors are found + * - when `scheduleRequest` rejects on requests this function can do + * without. + * @param {CancellationSignal} cancelSignal - Cancellation signal which will + * allow to abort the parsing operation if you do not want the 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. + * + * `parseManifest` will interrupt all operations if the signal has been + * triggered in one of those scenarios, and will automatically reject with + * the corresponding `CancellationError` instance. + * @param {Function} scheduleRequest - Allows `parseManifest` to schedule + * network requests, for example to fetch sub-parts of the Manifest or + * supplementary resources we can only know of at Manifest parsing time. + * + * All requests scheduled through `scheduleRequest` should abort (and the + * corresponding Promise reject a `CancellationError`) when/if `cancelSignal` + * is triggered. + * + * If a request scheduled through `scheduleRequest` rejects with an error: + * - either the error was due to a cancellation, in which case + * `parseManifest` should reject the same error immediately. + * - either the requested resource was mandatory to parse the Manifest + * in which case `parseManifest` will reject with the same error. + * - either the parser can make up for that error, in which case it will + * just be emitted as a warning and `parseManifest` will continue its + * operations. + * @returns {Object | Promise.} - Returns directly the Manifest data + * if the parsing can be performed synchronously or through a Promise if it + * needs to perform network requests first through the `scheduleRequest` + * function. + * + * Throws if an error happens synchronously and rejects if it happens + * asynchronously. + * + * If this error is due to a failed request performed through the + * `scheduleRequest` argument, then the rejected error should be the same one + * than the one rejected by `scheduleRequest`. + * + * If this error is due to a cancellation instead (indicated through the + * `cancelSignal` argument), then the rejected error should be the + * `CancellationError` instance instead. + */ + parseManifest : ( + manifestData : IRequestedData, + parserOptions : IManifestParserOptions, + onWarnings : (warnings : Error[]) => void, + cancelSignal : CancellationSignal, + scheduleRequest : IManifestParserRequestScheduler + ) => IManifestParserResult | + Promise; -/** - * "Parser" of the Manifest pipeline, allowing to parse a loaded Manifest so - * it can be exploited by the rest of the RxPlayer's logic. - * - * @param {Object} x - * @returns {Observable.} - */ -export type IManifestParserFunction = ( - x : IManifestParserArguments -) => Observable< IManifestParserResponseEvent | - IManifestParserWarningEvent>; + /** + * @deprecated + * "Resolves the Manifest's URL, to obtain its true URL. + * This is a deprecated function which corresponds to an old use case at + * Canal+ where the URL of the Manifest first need to be parsed from a .wsx + * file. + * Thankfully this API should not be used anymore, though to not break + * compatibility, we have to keep it until a v4.x.x release. + * + * @param {string | undefined} url - URL used to obtain the real URL of the + * Manifest. + * @param {CancellationSignal} cancelSignal - Cancellation signal which will + * allow to abort the resolving operation if you do not want the Manifest + * anymore. + * When cancelled, the promise returned by this function will reject with a + * `CancellationError`. + * @returns {Promise.} - Promise emitting the "real" URL of + * the Manifest, that should be loaded by the `loadManifest` function. + * `undefined` if the URL is either unknown or inexistant. + * + * Rejects in two cases: + * + * 1. The resolving operation has been aborted through the `cancelSignal` + * given in argument. + * In that case, this Promise will reject a `CancellationError`. + * + * 2. The resolving operation failed, most likely due to a request error. + * In that case, this Promise will reject the corresponding Error. + */ + resolveManifestUrl? : ( + url : string | undefined, + cancelSignal : CancellationSignal, + ) => Promise; +} /** Functions allowing to load and parse segments of any type. */ export interface ISegmentPipeline< TLoadedFormat, TParsedSegmentDataFormat, > { - loader : ISegmentLoader; - parser : ISegmentParser; + loadSegment : ISegmentLoader; + parseSegment : ISegmentParser; } /** * Segment loader function, allowing to load a segment of any type. - * @param {Object} x - * @returns {Observable.} + * @param {stop|null} url - URL at which the segment should be downloaded. + * `null` if we do not have an URL (in which case the segment should be loaded + * through other means, such as information taken from the segment's content). + * @param {Object} content - Content linked to the wanted segment. + * @param {CancellationSignal} cancelSignal - Cancellation signal which will + * allow to cancel the loading operation if the segment is not needed anymore. + * + * When cancelled, this loader should stop any pending operation (such as an + * HTTP request) and the Promise returned should reject immediately with a + * `CancellationError`, generated through this CancellationSignal object. + * @param {Object} callbacks - Callbacks called on various loader events. + * @returns {Promise.} - Promise resolving when it has finished loading + * the segment. */ export type ISegmentLoader = ( - x : ISegmentLoaderArguments -) => Observable>; + url : string | null, + content : ISegmentContext, + cancelSignal : CancellationSignal, + callbacks : ISegmentLoaderCallbacks +) => Promise | + ISegmentLoaderResultSegmentLoaded | + ISegmentLoaderResultChunkedComplete>; /** * Segment parser function, allowing to parse a segment of any type. - * @param {Object} x - * @returns {Observable.} + * + * This function will throw if it encounters any error it cannot recover from. */ export type ISegmentParser< TLoadedFormat, TParsedSegmentDataFormat > = ( - x : ISegmentParserArguments< TLoadedFormat > + /** Attributes of the corresponding loader's response. */ + loadedSegment : { + /** The loaded segment data. */ + data : TLoadedFormat; + /** + * If `true`,`data` is only a "chunk" of the whole segment (which potentially + * will contain multiple chunks). + * If `false`, `data` is the data for the whole segment. + */ + isChunked : boolean; + }, + /** Context about the wanted segment. */ + content : ISegmentContext, + /** + * "Timescale" obtained from parsing the wanted representation's initialization + * segment. + * + * `undefined` if either no such `timescale` has been parsed yet or if this + * value doesn't exist for the wanted segment. + * + * This value can be useful when parsing the loaded segment's data. + */ + initTimescale : number | undefined ) => /** * The parsed data. @@ -151,296 +274,414 @@ export type ISegmentParser< ISegmentParserParsedInitSegment | ISegmentParserParsedSegment; -/** Arguments for the loader of the manifest pipeline. */ -export interface IManifestLoaderArguments { +export interface IManifestParserOptions { /** - * URL of the Manifest we want to load. - * `undefined` if the Manifest doesn't have an URL linked to it, in which - * case the Manifest should be loaded from another mean. + * If set, offset to add to `performance.now()` to obtain the current + * server's time. */ - url : string | undefined; + externalClockOffset : number | undefined; + /** Original URL used for the full version of the Manifest. */ + originalUrl : string | undefined; + /** The previous value of the Manifest (when updating). */ + previousManifest : Manifest | null; + /** + * If set to `true`, the Manifest parser can perform advanced optimizations + * to speed-up the parsing process. Those optimizations might lead to a + * de-synchronization with what is actually on the server, hence the "unsafe" + * part. + * To use with moderation and only when needed. + */ + unsafeMode : boolean; } -/** Arguments for the loader of the segment pipelines. */ -export interface ISegmentLoaderArguments { - /** Manifest object related to this segment. */ - manifest : Manifest; - /** Period object related to this segment. */ - period : Period; - /** Adaptation object related to this segment. */ - adaptation : Adaptation; - /** Representation Object related to this segment. */ - representation : Representation; - /** Segment we want to load. */ - segment : ISegment; +export interface IManifestParserCallbacks { + onWarning : (warning : Error) => void; + /** - * URL at which the segment should be downloaded. - * `null` if we do not have an URL (in which case the segment should be loaded - * through an other mean). + * @param {Function} performRequest - Function performing the request + * @param {TaskCanceller} canceller - Interface allowing to cancel the request + * performed by the `performRequest` argument. + * @returns {Promise.} */ - url : string | null; + scheduleRequest : ( + performRequest : () => Promise< IRequestedData< Document | string > >, + canceller : TaskCanceller + ) => Promise< IRequestedData< Document | string > >; } -/** Payload of a "data-loaded" event. */ -export interface ILoaderDataLoadedValue { - /** The loaded response data. */ - responseData : T; - /** Duration the request took to be performed, in seconds. */ - duration : number | undefined; +/** + * Function allowing a Manifest parser to perform a request needed for the + * parsing of the Manifest. + * + * @param {Function} performRequest - Function performing the wanted request. + * Note that this function might be called multiple times depending on the error + * obtained at the last call. + * + * Should resolve with the requested data on success. + * + * Rejects in two cases: + * - The request has been cancelled through the `canceller` given. + * In that case, this Promise will reject with a `CancellationError`. + * + * - The request failed. + * In that case, this Promise will reject with the corresponding Error. + * + * @param {TaskCanceller} canceller - Interface allowing to cancel the request + * performed by the `performRequest` argument. + * + * When triggered, the request should be aborted and the Promise returned by + * `performRequest` should reject the corresponding `CancellationError`. + * + * The Promise returned by that function should in consequence also reject the + * same `CancellationError`. + * + * @returns {Promise.} - Promise resolving with the requested data on + * success. + * + * Rejects in two cases: + * - The request has been cancelled through the `canceller` given. + * In that case, this Promise will reject with a `CancellationError`. + * + * - All the attempts to perform the request failed. + * In that case, this Promise will reject with the Error corresponding to + * the last performed request. + */ +export type IManifestParserRequestScheduler = + ( + performRequest : () => Promise< IRequestedData< ILoadedManifestFormat > > + ) => Promise< IRequestedData< ILoadedManifestFormat > >; + +// Either the Manifest can be parsed directly, in which case a +// IManifestParserResult is returned, either the Manifest parser needs to +// perform supplementary requests first + +/** Event emitted when a Manifest has been parsed by a Manifest parser. */ +export interface IManifestParserResult { + /** The parsed Manifest Object itself. */ + manifest : Manifest; /** - * "Real" URL (post-redirection) at which the data can be loaded. + * "Real" URL (post-redirection) at which the Manifest can be refreshed. * - * Note that this doesn't always apply e.g. some data might need multiple + * Note that this doesn't always apply e.g. some Manifest might need multiple * URLs to be fetched, some other might need to fetch no URL. * This property should only be set when a unique URL is sufficient to * retrieve the whole data. */ url? : string; +} + +/** + * Allow the parser to ask for loading supplementary ressources while still + * profiting from the same retries and error management than the loader. + */ +export interface IManifestParserRequestNeeded { + resultType : "request-needed"; + performRequest : IManifestParserRequest; +} + +/** + * Time information for a single segment. + * Those variables expose the best guess we have on the effective duration and + * starting time that the corresponding segment should have at decoding time. + */ +export interface IChunkTimeInfo { /** - * Time at which the request began in terms of `performance.now`. - * If fetching the corresponding data necessitated to perform multiple - * requests, this time corresponds to the first request made. + * Difference between the latest and the earliest presentation time + * available in that segment, in seconds. + * + * Either `undefined` or set to `0` for an initialization segment. */ - sendingTime? : number; + duration : number | undefined; + /** Earliest presentation time available in that segment, in seconds. */ + time : number; +} + +/** Text track segment data, once parsed. */ +export interface ITextTrackSegmentData { + /** The text track data, in the format indicated in `type`. */ + data : string; + /** The format of `data` (examples: "ttml", "srt" or "vtt") */ + type : string; /** - * Time at which the request ended in terms of `performance.now`. - * If fetching the corresponding data necessitated to perform multiple - * requests, this time corresponds to the last request to end. + * Language in which the text track is, as a language code. + * This is mostly needed for "sami" subtitles, to know which cues can / should + * be parsed. */ - receivedTime? : number; - /** Size in bytes of the loaded data. `undefined` if we don't know. */ - size : number | undefined; + language? : string; + /** start time from which the segment apply, in seconds. */ + start? : number; + /** end time until which the segment apply, in seconds. */ + end? : number; } -/** Form that can take a loaded Manifest once loaded. */ -export type ILoadedManifest = Document | - ArrayBuffer | - string | - IMetaPlaylist | - ILocalManifest | - Manifest; - -/** Event emitted by a Manifest loader when the Manifest is fully available. */ -export interface IManifestLoaderDataLoadedEvent { - type : "data-loaded"; - value : ILoaderDataLoadedValue; +/** Format under which image data is decodable by the RxPlayer. */ +export interface IImageTrackSegmentData { + data : IBifThumbnail[]; // image track data, in the given type + end : number; // end time time until which the segment apply + start : number; // start time from which the segment apply + timescale : number; // timescale to convert the start and end into seconds + type : string; // the type of the data (example: "bif") } -/** Event emitted by a segment loader when the data has been fully loaded. */ -export interface ISegmentLoaderDataLoadedEvent { - type : "data-loaded"; - value : ILoaderDataLoadedValue; +export type IManifestParserRequest1 = ( + ( + /** + * Cancellation signal which will allow to cancel the request if the + * Manifest is not needed anymore. + * + * When cancelled, this parser should stop any pending operation (such as an + * HTTP request) and the Promise returned should reject immediately after with + * a `CancellationError`. + */ + cancelSignal : CancellationSignal, + ) => Promise< IRequestedData< Document | string > > +); +export type IManifestParserRequest = ( + /** + * Cancellation signal which will allow to cancel the request if the + * Manifest is not needed anymore. + * + * When cancelled, this parser should stop any pending operation (such as an + * HTTP request) and the Promise returned should reject immediately after with + * a `CancellationError`. + */ + cancelSignal : CancellationSignal, +) => Promise< IManifestParserResult | + IManifestParserRequestNeeded >; + +export interface ITransportAudioVideoSegmentPipeline { + loadSegment : ISegmentLoader; + parseSegment : ISegmentParser; } -/** - * Event emitted by a segment loader when the data is available without needing - * to perform any request. - * - * Such data are for example directly generated from already-available data, - * such as properties of a Manifest. - */ -export interface ISegmentLoaderDataCreatedEvent { - type : "data-created"; - value : { responseData : T }; +export interface ITransportTextSegmentPipeline { + loadSegment : ISegmentLoader; + parseSegment : ISegmentParser; } -/** - * Event emitted by a segment loader when new information on a pending request - * is available. - * - * Note that this event is not mandatory. - * It will be used to allow to communicate network metrics to the rest of the - * player, like to adapt the quality of the content depending on the user's - * bandwidth. - */ -export interface ILoaderProgressEvent { - type : "progress"; - value : { - /** Time since the beginning of the request so far, in seconds. */ - duration : number; - /** Size of the data already downloaded, in bytes. */ - size : number; - /** Size of whole data to download (data already-loaded included), in bytes. */ - totalSize? : number; - }; +export interface ITransportImageSegmentPipeline { + loadSegment : ISegmentLoader; + parseSegment : ISegmentParser; } -/** Event emitted by a segment loader when a chunk of the response is available. */ -export interface ISegmentLoaderDataChunkEvent { - type : "data-chunk"; - value : { - /** Loaded chunk, as raw data. */ - responseData: ArrayBuffer | - Uint8Array; - }; +export type ITransportSegmentPipeline = ITransportAudioVideoSegmentPipeline | + ITransportTextSegmentPipeline | + ITransportImageSegmentPipeline; + +export type ITransportPipeline = ITransportManifestPipeline | + ITransportSegmentPipeline; + +interface IServerSyncInfos { serverTimestamp : number; + clientTime : number; } + +export interface ITransportOptions { + aggressiveMode? : boolean; + checkMediaSegmentIntegrity? : boolean; + lowLatencyMode : boolean; + manifestLoader?: CustomManifestLoader; + manifestUpdateUrl? : string; + referenceDateTime? : number; + representationFilter? : IRepresentationFilter; + segmentLoader? : CustomSegmentLoader; + serverSyncInfos? : IServerSyncInfos; + /* eslint-disable import/no-deprecated */ + supplementaryImageTracks? : ISupplementaryImageTrack[]; + supplementaryTextTracks? : ISupplementaryTextTrack[]; + /* eslint-enable import/no-deprecated */ + + __priv_patchLastSegmentInSidx? : boolean; } -/** - * Event emitted by segment loaders when all data from a segment has been - * communicated through `ISegmentLoaderDataChunkEvent` events. - */ -export interface ISegmentLoaderDataChunkCompleteEvent { - type : "data-chunk-complete"; - value : { - /** Duration the request took to be performed, in seconds. */ - duration : number | undefined; - /** - * "Real" URL (post-redirection) at which the segment was loaded. - * - * Note that this doesn't always apply e.g. some segment might need multiple - * URLs to be fetched, some other might need to fetch no URL. - * This property should only be set when a unique URL is sufficient to - * retrieve the whole data. - */ - url? : string; - /** - * Time at which the request began in terms of `performance.now`. - * If fetching the corresponding data necessitated to perform multiple - * requests, this time corresponds to the first request made. - */ - sendingTime? : number; - /** - * Time at which the request ended in terms of `performance.now`. - * If fetching the corresponding data necessitated to perform multiple - * requests, this time corresponds to the last request to end. - */ - receivedTime? : number; - /** Size in bytes of the loaded data. `undefined` if we don't know. */ - size : number | undefined; - }; +export type CustomSegmentLoader = ( + // first argument: infos on the segment + args : { adaptation : Adaptation; + representation : Representation; + segment : ISegment; + transport : string; + url : string; + manifest : Manifest; }, + + // second argument: callbacks + callbacks : { resolve : (rArgs : { data : ArrayBuffer | Uint8Array; + sendingTime? : number; + receivingTime? : number; + size? : number; + duration? : number; }) + => void; + + progress : (pArgs : { duration : number; + size : number; + totalSize? : number; }) + => void; + reject : (err? : Error) => void; + fallback? : () => void; } +) => + // returns either the aborting callback or nothing + (() => void)|void; + +export type CustomManifestLoader = ( + // first argument: url of the manifest + url : string | undefined, + + // second argument: callbacks + callbacks : { resolve : (args : { data : ILoadedManifestFormat; + sendingTime? : number; + receivingTime? : number; + size? : number; + duration? : number; }) + => void; + + reject : (err? : Error) => void; + fallback? : () => void; } +) => + // returns either the aborting callback or nothing + (() => void)|void; + +export interface ISegmentContext { + /** Manifest object related to this segment. */ + manifest : Manifest; + /** Period object related to this segment. */ + period : Period; + /** Adaptation object related to this segment. */ + adaptation : Adaptation; + /** Representation Object related to this segment. */ + representation : Representation; + /** Segment we want to load. */ + segment : ISegment; } -/** - * Event sent by a segment loader when the corresponding segment is available - * chunk per chunk. - */ -export type ISegmentLoaderChunkEvent = ISegmentLoaderDataChunkEvent | - ISegmentLoaderDataChunkCompleteEvent; - -/** Event emitted by a Manifest loader. */ -export type IManifestLoaderEvent = IManifestLoaderDataLoadedEvent; - -/** Event emitted by a segment loader. */ -export type ISegmentLoaderEvent = ILoaderProgressEvent | - ISegmentLoaderChunkEvent | - ISegmentLoaderDataLoadedEvent | - ISegmentLoaderDataCreatedEvent; - -/** Arguments given to the `parser` function of the Manifest pipeline. */ -export interface IManifestParserArguments { - /** Response obtained from the loader. */ - response : ILoaderDataLoadedValue; - /** Original URL used for the full version of the Manifest. */ - url? : string; +export interface ISegmentLoaderCallbacks { /** - * If set, offset to add to `performance.now()` to obtain the current - * server's time. + * Callback called when new progress information on a segment request is + * available. + * The information emitted though this callback can be used to gather + * metrics on a current, un-terminated, request. */ - externalClockOffset? : number; - /** The previous value of the Manifest (when updating). */ - previousManifest : Manifest | null; + onProgress : (info : ISegmentLoadingProgressInformation) => void; /** - * Allow the parser to ask for loading supplementary ressources while still - * profiting from the same retries and error management than the loader. - */ - scheduleRequest : (request : () => - Observable< ILoaderDataLoadedValue< string | Document | ArrayBuffer > >) => - Observable< ILoaderDataLoadedValue< string | Document | ArrayBuffer > >; - /** - * If set to `true`, the Manifest parser can perform advanced optimizations - * to speed-up the parsing process. Those optimizations might lead to a - * de-synchronization with what is actually on the server, hence the "unsafe" - * part. - * To use with moderation and only when needed. - */ - unsafeMode : boolean; -} - -/** Arguments given to the `parser` function of the segment pipeline. */ -export interface ISegmentParserArguments { - /** Attributes of the corresponding loader's response. */ - response : { - /** - * The loaded data. - * - * Possibly in Uint8Array or ArrayBuffer form if chunked (see `isChunked` - * property) or loaded through custom loaders. - */ - data: T | Uint8Array | ArrayBuffer; - /** - * If `true`,`data` is only a "chunk" of the whole segment (which potentially - * will contain multiple chunks). - * If `false`, `data` is the data for the whole segment. - */ - isChunked : boolean; - }; - /** - * "Timescale" obtained from parsing the wanted representation's initialization + * Callback called when a decodable sub-part of the segment is available. + * + * Note that this callback is only called if the loader decides to load the + * wanted segment in a "chunk" mode, that is, when the segment is loaded + * decodable chunk by decodable chunk, each being a subpart of this same * segment. * - * `undefined` if either no such `timescale` has been parsed yet or if this - * value doesn't exist for the wanted segment. + * In that case, this callback might be called multiple times for subsequent + * decodable chunks until the Promise resolves. * - * This value can be useful when parsing the loaded segment's data. + * Not all segments are loaded in a "chunk" mode. + * The alternatives to this mode are: + * + * - when the segment is created locally without needing to perform any + * request. + * + * - when the segment is loaded as a whole. + * + * In both of those other cases, the segment data can be retrieved in the + * Promise returned by the segment loader instead. */ - initTimescale? : number; - /** Context about the wanted segment. */ - content : { - /** Manifest object related to this segment. */ - manifest : Manifest; - /** Period object related to this segment. */ - period : Period; - /** Adaptation object related to this segment. */ - adaptation : Adaptation; - /** Representation Object related to this segment. */ - representation : Representation; - /** Segment we want to parse. */ - segment : ISegment; - }; + onNewChunk : (data : T) => void; } -/** Event emitted when a Manifest object has been parsed. */ -export interface IManifestParserResponseEvent { - type : "parsed"; - value: { - /** The parsed Manifest Object itself. */ - manifest : Manifest; - /** - * "Real" URL (post-redirection) at which the Manifest can be refreshed. - * - * Note that this doesn't always apply e.g. some Manifest might need multiple - * URLs to be fetched, some other might need to fetch no URL. - * This property should only be set when a unique URL is sufficient to - * retrieve the whole data. - */ - url? : string; - }; +export interface ISegmentLoadingProgressInformation { + /** Time since the beginning of the request so far, in seconds. */ + duration : number; + /** Size of the data already downloaded, in bytes. */ + size : number; + /** Size of whole data to download (data already-loaded included), in bytes. */ + totalSize? : number; } -/** Event emitted when a minor error was encountered when parsing the Manifest. */ -export interface IManifestParserWarningEvent { - type : "warning"; - /** Error describing the minor parsing error encountered. */ - value : Error; +/** + * Result returned by a segment loader when a segment has been loaded in a + * "chunk" mode. + * In that mode, the segment has been divided into multiple decodable chunks + * each sent in order through the `onNewChunk` callback of the corresponding + * loader. + */ +export interface ISegmentLoaderResultChunkedComplete { + resultType : "chunk-complete"; + /** Information on the request performed. */ + resultData : IChunkCompleteInformation; } /** - * Time information for a single segment. - * Those variables expose the best guess we have on the effective duration and - * starting time that the corresponding segment should have at decoding time. + * Result returned by a segment loader when a segment has been loaded + * by performing a request. */ -export interface IChunkTimeInfo { +export interface ISegmentLoaderResultSegmentLoaded { + resultType : "segment-loaded"; + /** Segment data and information on the request. */ + resultData : IRequestedData; +} + +/** + * Result returned by a segment loader when a segment has been fully + * created locally and thus did not depend on a request. + * TODO merge with ISegmentLoaderResultSegmentLoaded? + */ +export interface ISegmentLoaderResultSegmentCreated { + resultType : "segment-created"; + /** The data iself. */ + resultData : T; +} + +/** Data emitted in a `ISegmentLoaderResultChunkedComplete`. */ +export interface IChunkCompleteInformation { + /** Duration the request took to be performed, in seconds. */ + duration : number | undefined; /** - * Difference between the latest and the earliest presentation time - * available in that segment, in seconds. + * "Real" URL (post-redirection) at which the segment was loaded. * - * Either `undefined` or set to `0` for an initialization segment. + * Note that this doesn't always apply e.g. some segment might need multiple + * URLs to be fetched, some other might need to fetch no URL. + * This property should only be set when a unique URL is sufficient to + * retrieve the whole data. */ - duration : number | undefined; - /** Earliest presentation time available in that segment, in seconds. */ - time : number; + url? : string; + /** + * Time at which the request began in terms of `performance.now`. + * If fetching the corresponding data necessitated to perform multiple + * requests, this time corresponds to the first request made. + */ + sendingTime? : number; + /** + * Time at which the request ended in terms of `performance.now`. + * If fetching the corresponding data necessitated to perform multiple + * requests, this time corresponds to the last request to end. + */ + receivedTime? : number; + /** Size in bytes of the loaded data. `undefined` if we don't know. */ + size : number | undefined; } +/** Format of a loaded Manifest before parsing. */ +export type ILoadedManifestFormat = Document | + string | + ArrayBuffer | + IMetaPlaylist | + ILocalManifest | + Manifest; + +/** Format of a loaded audio and video segment before parsing. */ +export type ILoadedAudioVideoSegmentFormat = Uint8Array | + ArrayBuffer | + null; + +/** Format of a loaded text segment before parsing. */ +export type ILoadedTextSegmentFormat = Uint8Array | + ArrayBuffer | + string | + null; + +/** Format of a loaded image segment before parsing. */ +export type ILoadedImageSegmentFormat = Uint8Array | + ArrayBuffer | + null; + /** Result returned by a segment parser when it parsed an initialization segment. */ export interface ISegmentParserParsedInitSegment { segmentType : "init"; @@ -448,7 +689,7 @@ export interface ISegmentParserParsedInitSegment { * Initialization segment that can be directly pushed to the corresponding * buffer. */ - initializationData : DataType; + initializationData : DataType | null; /** * Timescale metadata found inside this initialization segment. * That timescale might be useful when parsing further merdia segments. @@ -469,13 +710,13 @@ export interface ISegmentParserParsedInitSegment { } /** - * Result returned by a segment parser when it parsed a media segment (not an + * Result returned by a segment parser when it parsed a media (not an * initialization segment). */ export interface ISegmentParserParsedSegment { segmentType : "media"; /** Parsed chunk of data that can be decoded. */ - chunkData : DataType; + chunkData : DataType | null; /** Time information on this parsed chunk. */ chunkInfos : IChunkTimeInfo | null; /** @@ -522,100 +763,33 @@ export interface ISegmentParserParsedSegment { protectionDataUpdate : boolean; } -/** Text track segment data, once parsed. */ -export interface ITextTrackSegmentData { - /** The text track data, in the format indicated in `type`. */ - data : string; - /** The format of `data` (examples: "ttml", "srt" or "vtt") */ - type : string; +/** Describe data loaded through a request. */ +export interface IRequestedData { + /** The loaded response data. */ + responseData : T; + /** Duration the request took to be performed, in seconds. */ + duration : number | undefined; /** - * Language in which the text track is, as a language code. - * This is mostly needed for "sami" subtitles, to know which cues can / should - * be parsed. + * "Real" URL (post-redirection) at which the data can be loaded. + * + * Note that this doesn't always apply e.g. some data might need multiple + * URLs to be fetched, some other might need to fetch no URL. + * This property should only be set when a unique URL is sufficient to + * retrieve the whole data. */ - language? : string; - /** start time from which the segment apply, in seconds. */ - start? : number; - /** end time until which the segment apply, in seconds. */ - end? : number; -} - -/** Format under which image data is decodable by the RxPlayer. */ -export interface IImageTrackSegmentData { - /** Exploitable image track data. */ - data : IBifThumbnail[]; - /** End time time until which the segment apply, in the timescale given. */ - end : number; - /** Start time time from which the segment apply, in the timescale given. */ - start : number; - /** Timescale to convert the `start` and `end` properties into seconds. */ - timescale : number; - /** The format the data is in (example: "bif"). */ - type : "bif"; -} - -interface IServerSyncInfos { serverTimestamp : number; - clientTime : number; } - -export interface ITransportOptions { - aggressiveMode? : boolean; - checkMediaSegmentIntegrity? : boolean; - lowLatencyMode : boolean; - manifestLoader?: CustomManifestLoader; - manifestUpdateUrl? : string; - referenceDateTime? : number; - representationFilter? : IRepresentationFilter; - segmentLoader? : CustomSegmentLoader; - serverSyncInfos? : IServerSyncInfos; - /* eslint-disable import/no-deprecated */ - supplementaryImageTracks? : ISupplementaryImageTrack[]; - supplementaryTextTracks? : ISupplementaryTextTrack[]; - /* eslint-enable import/no-deprecated */ - - __priv_patchLastSegmentInSidx? : boolean; + url? : string; + /** + * Time at which the request began in terms of `performance.now`. + * If fetching the corresponding data necessitated to perform multiple + * requests, this time corresponds to the first request made. + */ + sendingTime? : number; + /** + * Time at which the request ended in terms of `performance.now`. + * If fetching the corresponding data necessitated to perform multiple + * requests, this time corresponds to the last request to end. + */ + receivedTime? : number; + /** Size in bytes of the loaded data. `undefined` if we don't know. */ + size : number | undefined; } - -export type CustomSegmentLoader = ( - // first argument: infos on the segment - args : { adaptation : Adaptation; - representation : Representation; - segment : ISegment; - transport : string; - url : string; - manifest : Manifest; }, - - // second argument: callbacks - callbacks : { resolve : (rArgs : { data : ArrayBuffer | Uint8Array; - sendingTime? : number; - receivingTime? : number; - size? : number; - duration? : number; }) - => void; - - progress : (pArgs : { duration : number; - size : number; - totalSize? : number; }) - => void; - reject : (err? : Error) => void; - fallback? : () => void; } -) => - // returns either the aborting callback or nothing - (() => void)|void; - -export type CustomManifestLoader = ( - // first argument: url of the manifest - url : string | undefined, - - // second argument: callbacks - callbacks : { resolve : (args : { data : ILoadedManifest; - sendingTime? : number; - receivingTime? : number; - size? : number; - duration? : number; }) - => void; - - reject : (err? : Error) => void; - fallback? : () => void; } -) => - // returns either the aborting callback or nothing - (() => void)|void; diff --git a/src/transports/utils/call_custom_manifest_loader.ts b/src/transports/utils/call_custom_manifest_loader.ts index 8bec14632d..ce1df0460d 100644 --- a/src/transports/utils/call_custom_manifest_loader.ts +++ b/src/transports/utils/call_custom_manifest_loader.ts @@ -14,61 +14,67 @@ * limitations under the License. */ -import { - Observable, - Observer, -} from "rxjs"; +import PPromise from "pinkie"; import { CustomLoaderError } from "../../errors"; +import { + CancellationError, + CancellationSignal, +} from "../../utils/task_canceller"; import { CustomManifestLoader, - ILoadedManifest, - IManifestLoaderArguments, - IManifestLoaderEvent, + ILoadedManifestFormat, + IRequestedData, } from "../types"; export default function callCustomManifestLoader( customManifestLoader : CustomManifestLoader, fallbackManifestLoader : ( - x : IManifestLoaderArguments - ) => Observable< IManifestLoaderEvent > -) : (x : IManifestLoaderArguments) => Observable< IManifestLoaderEvent > { - - return (args : IManifestLoaderArguments) : Observable< IManifestLoaderEvent > => { - return new Observable((obs: Observer) => { - const { url } = args; + url : string | undefined, + cancelSignal : CancellationSignal + ) => Promise< IRequestedData > +) : ( + url : string | undefined, + cancelSignal : CancellationSignal + ) => Promise< IRequestedData > +{ + return ( + url : string | undefined, + cancelSignal : CancellationSignal + ) : Promise< IRequestedData > => { + return new PPromise((res, rej) => { const timeAPIsDelta = Date.now() - performance.now(); + /** `true` when the custom segmentLoader should not be active anymore. */ let hasFinished = false; - let hasFallbacked = false; /** * Callback triggered when the custom manifest loader has a response. * @param {Object} args */ - const resolve = (_args : { data : ILoadedManifest; + const resolve = (_args : { data : ILoadedManifestFormat; size? : number; duration? : number; url? : string; receivingTime? : number; sendingTime? : number; }) => { - if (!hasFallbacked) { - hasFinished = true; - const receivedTime = - _args.receivingTime !== undefined ? _args.receivingTime - timeAPIsDelta : - undefined; - const sendingTime = - _args.sendingTime !== undefined ? _args.sendingTime - timeAPIsDelta : + if (hasFinished || cancelSignal.isCancelled) { + return; + } + hasFinished = true; + cancelSignal.deregister(abortCustomLoader); + + const receivedTime = + _args.receivingTime !== undefined ? _args.receivingTime - timeAPIsDelta : undefined; + const sendingTime = + _args.sendingTime !== undefined ? _args.sendingTime - timeAPIsDelta : + undefined; - obs.next({ type: "data-loaded", - value: { responseData: _args.data, - size: _args.size, - duration: _args.duration, - url: _args.url, - receivedTime, - sendingTime } }); - obs.complete(); - } + res({ responseData: _args.data, + size: _args.size, + duration: _args.duration, + url: _args.url, + receivedTime, sendingTime }); }; /** @@ -76,23 +82,25 @@ export default function callCustomManifestLoader( * @param {*} err - The corresponding error encountered */ const reject = (err : unknown) : void => { - if (!hasFallbacked) { - hasFinished = true; - - // Format error and send it - const castedErr = err as (null | undefined | { message? : string; - canRetry? : boolean; - isOfflineError? : boolean; - xhr? : XMLHttpRequest; }); - const message = castedErr?.message ?? - "Unknown error when fetching the Manifest through a " + - "custom manifestLoader."; - const emittedErr = new CustomLoaderError(message, - castedErr?.canRetry ?? false, - castedErr?.isOfflineError ?? false, - castedErr?.xhr); - obs.error(emittedErr); + if (hasFinished || cancelSignal.isCancelled) { + return; } + hasFinished = true; + cancelSignal.deregister(abortCustomLoader); + + // Format error and send it + const castedErr = err as (null | undefined | { message? : string; + canRetry? : boolean; + isOfflineError? : boolean; + xhr? : XMLHttpRequest; }); + const message = castedErr?.message ?? + "Unknown error when fetching the Manifest through a " + + "custom manifestLoader."; + const emittedErr = new CustomLoaderError(message, + castedErr?.canRetry ?? false, + castedErr?.isOfflineError ?? false, + castedErr?.xhr); + rej(emittedErr); }; /** @@ -100,18 +108,33 @@ export default function callCustomManifestLoader( * the "regular" implementation */ const fallback = () => { - hasFallbacked = true; - fallbackManifestLoader(args).subscribe(obs); + if (hasFinished || cancelSignal.isCancelled) { + return; + } + hasFinished = true; + cancelSignal.deregister(abortCustomLoader); + fallbackManifestLoader(url, cancelSignal).then(res, rej); }; const callbacks = { reject, resolve, fallback }; const abort = customManifestLoader(url, callbacks); - return () => { - if (!hasFinished && !hasFallbacked && typeof abort === "function") { + cancelSignal.register(abortCustomLoader); + + /** + * The logic to run when the custom loader is cancelled while pending. + * @param {Error} err + */ + function abortCustomLoader(err : CancellationError) { + if (hasFinished) { + return; + } + hasFinished = true; + if (typeof abort === "function") { abort(); } - }; + rej(err); + } }); }; } diff --git a/src/transports/utils/generate_manifest_loader.ts b/src/transports/utils/generate_manifest_loader.ts index c84b91f365..6bcb720725 100644 --- a/src/transports/utils/generate_manifest_loader.ts +++ b/src/transports/utils/generate_manifest_loader.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import { Observable } from "rxjs"; -import isNullOrUndefined from "../../utils/is_null_or_undefined"; +import assertUnreachable from "../../utils/assert_unreachable"; import request from "../../utils/request"; +import { CancellationSignal } from "../../utils/task_canceller"; import { CustomManifestLoader, - IManifestLoaderArguments, - IManifestLoaderEvent, + ILoadedManifestFormat, + IRequestedData, } from "../types"; import callCustomManifestLoader from "./call_custom_manifest_loader"; @@ -31,14 +31,32 @@ import callCustomManifestLoader from "./call_custom_manifest_loader"; */ function generateRegularManifestLoader( preferredType: "arraybuffer" | "text" | "document" -) : (arg : IManifestLoaderArguments) => Observable < IManifestLoaderEvent > { +) : ( + url : string | undefined, + cancelSignal : CancellationSignal + ) => Promise < IRequestedData > +{ return function regularManifestLoader( - { url } : IManifestLoaderArguments - ) : Observable< IManifestLoaderEvent > { + url : string | undefined, + cancelSignal : CancellationSignal + ) : Promise< IRequestedData > { if (url === undefined) { throw new Error("Cannot perform HTTP(s) request. URL not known"); } - return request({ url, responseType: preferredType }); + + // What follows could be written in a single line, but TypeScript wouldn't + // shut up. + // So I wrote that instead, temporarily of course ;) + switch (preferredType) { + case "arraybuffer": + return request({ url, responseType: "arraybuffer", cancelSignal }); + case "text": + return request({ url, responseType: "text", cancelSignal }); + case "document": + return request({ url, responseType: "document", cancelSignal }); + default: + assertUnreachable(preferredType); + } }; } @@ -50,9 +68,13 @@ function generateRegularManifestLoader( export default function generateManifestLoader( { customManifestLoader } : { customManifestLoader?: CustomManifestLoader }, preferredType: "arraybuffer" | "text" | "document" -) : (x : IManifestLoaderArguments) => Observable< IManifestLoaderEvent > { +) : ( + url : string | undefined, + cancelSignal : CancellationSignal + ) => Promise> +{ const regularManifestLoader = generateRegularManifestLoader(preferredType); - if (isNullOrUndefined(customManifestLoader)) { + if (typeof customManifestLoader !== "function") { return regularManifestLoader; } return callCustomManifestLoader(customManifestLoader, diff --git a/src/transports/utils/return_parsed_manifest.ts b/src/transports/utils/return_parsed_manifest.ts deleted file mode 100644 index 08776e88b3..0000000000 --- a/src/transports/utils/return_parsed_manifest.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * 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 { - concat as observableConcat, - Observable, - of as observableOf, -} from "rxjs"; -import Manifest from "../../manifest"; -import { - IManifestParserResponseEvent, - IManifestParserWarningEvent, -} from "../types"; - -/** - * As a Manifest instance is obtained, emit the right `warning` events - * (according to the Manifest's `parsingErrors` property`) followed by the right - * `parsed` event, as expected from a Manifest parser. - * @param {Manifest} manifest - * @param {string|undefined} url - * @returns {Observable} - */ -export default function returnParsedManifest( - manifest : Manifest, - url? : string -) : Observable { - const warningEvts$ = observableOf(...manifest.parsingErrors.map(error => ({ - type: "warning" as const, - value: error, - }))); - return observableConcat(warningEvts$, - observableOf({ type: "parsed" as const, - value: { manifest, url } })); -} diff --git a/src/utils/cancellable_sleep.ts b/src/utils/cancellable_sleep.ts new file mode 100644 index 0000000000..2875ccc783 --- /dev/null +++ b/src/utils/cancellable_sleep.ts @@ -0,0 +1,51 @@ +/** + * 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 PPromise from "pinkie"; +import { + CancellationError, + CancellationSignal, +} from "./task_canceller"; + +/** + * Wait the given `delay`, resolving the Promise when finished. + * + * The `cancellationSignal` given allows to cancel that timeout. In the case it + * is triggered before the timeout ended, this function will reject the + * corresponding `CancellationError` through the returned Promise. + * + * @param {number} delay - Delay to wait, in milliseconds + * @param {Object} cancellationSignal - `CancellationSignal` allowing to abort + * the timeout. + * @returns {Promise} - Resolve on timeout completion, rejects on timeout + * cancellation with the corresponding `CancellationError`. + */ +export default function cancellableSleep( + delay: number, + cancellationSignal: CancellationSignal +) : Promise { + return new PPromise((res, rej) => { + const timeout = setTimeout(() => { + unregisterCancelSignal(); + res(); + }, delay); + const unregisterCancelSignal = cancellationSignal + .register(function onCancel(cancellationError : CancellationError) { + clearTimeout(timeout); + rej(cancellationError); + }); + }); +} diff --git a/src/utils/id_generator.ts b/src/utils/id_generator.ts index 249fdc7ad8..30757d48bf 100644 --- a/src/utils/id_generator.ts +++ b/src/utils/id_generator.ts @@ -17,7 +17,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /** - * Creates an ID generator which generates an ID each time you call it. + * Creates an ID generator which generates a number containing an incremented + * number each time you call it. * @returns {Function} */ export default function idGenerator() : () => string { diff --git a/src/utils/request/fetch.ts b/src/utils/request/fetch.ts index 651de3139d..e60fadd200 100644 --- a/src/utils/request/fetch.ts +++ b/src/utils/request/fetch.ts @@ -14,9 +14,7 @@ * limitations under the License. */ -import { - Observable, -} from "rxjs"; +import PPromise from "pinkie"; import config from "../../config"; import { NetworkErrorTypes, @@ -24,36 +22,78 @@ import { } from "../../errors"; import log from "../../log"; import isNullOrUndefined from "../is_null_or_undefined"; - -export interface IDataChunk { - type : "data-chunk"; - value : { - currentTime : number; - duration : number; - chunkSize : number; - size : number; - sendingTime : number; - url : string; - totalSize? : number; - chunk: ArrayBuffer; - }; +import { + CancellationError, + CancellationSignal, +} from "../task_canceller"; + +/** Object returned by `fetchRequest` after the fetch operation succeeded. */ +export interface IFetchedStreamComplete { + /** Duration of the whole request, in milliseconds. */ + duration : number; + /** Result of `performance.now()` at the time the request was received. */ + receivedTime : number; + /** Result of `performance.now()` at the time the request was started. */ + sendingTime : number; + /** Size of the entire emitted data, in bytes. */ + size : number; + /** HTTP status of the request performed. */ + status : number; + /** URL of the recuperated data (post-redirection if one). */ + url : string; } -export interface IDataComplete { - type : "data-complete"; - value : { - duration : number; - receivedTime : number; - sendingTime : number; - size : number; - status : number; - url : string; - }; +/** Object emitted by `fetchRequest` when a new chunk of the data is available. */ +export interface IFetchedDataObject { + /** Result of `performance.now()` at the time this data was recuperated. */ + currentTime : number; + /** Duration of the request until `currentTime`, in milliseconds. */ + duration : number; + /** Size in bytes of the data emitted as `chunk`. */ + chunkSize : number; + /** Cumulated size of the received data by the request until now. */ + size : number; + /** Result of `performance.now()` at the time the request began. */ + sendingTime : number; + /** URL of the recuperated data (post-redirection if one). */ + url : string; + /** + * Value of the "Content-Length" header, which should (yet also might not be) + * the size of the complete data that will be fetched. + */ + totalSize? : number; + /** + * Current available chunk, which might only be a sub-part of the whole + * data. + * To retrieve the whole data, all `chunk` received from `fetchRequest` can be + * concatenated. + */ + chunk: ArrayBuffer; } +/** Options for the `fetchRequest` utils function. */ export interface IFetchOptions { + /** URL you want to perform the HTTP GET request on. */ url : string; + /** + * Callback called as new data is available. + * This callback might be called multiple times with chunks of the complete + * data until the fetch operation is finished. + */ + onData : (data : IFetchedDataObject) => void; + /** + * Signal allowing to cancel the fetch operation. + * If cancellation happens while the request is pending, `fetchRequest` will + * reject with the corresponding `CancellationError`. + */ + cancelSignal : CancellationSignal; + /** Optional headers for the HTTP GET request perfomed by `fetchRequest`. */ headers? : { [ header: string ] : string }|null; + /** + * Optional timeout for the HTTP GET request perfomed by `fetchRequest`. + * This timeout is just enabled until the HTTP response from the server, even + * if not all data has been received yet. + */ timeout? : number; } @@ -70,9 +110,9 @@ const _AbortController : IAbortControllerConstructor|null = AbortController : null; -function fetchRequest( +export default function fetchRequest( options : IFetchOptions -) : Observable< IDataChunk | IDataComplete > { +) : PPromise { let headers : Headers | { [key : string ] : string } | undefined; if (!isNullOrUndefined(options.headers)) { if (isNullOrUndefined(_Headers)) { @@ -87,127 +127,114 @@ function fetchRequest( } } - return new Observable((obs) => { - log.debug("Fetch: Called with URL", options.url); - let hasAborted = false; - let timeouted = false; - let isDone = false; - const sendingTime = performance.now(); - const abortController: AbortController | null = - !isNullOrUndefined(_AbortController) ? new _AbortController() : - null; - - /** - * Abort current fetchRequest by triggering AbortController signal. - * @returns {void} - */ - function abortRequest(): void { - if (!isDone) { - if (!isNullOrUndefined(abortController)) { - abortController.abort(); - return; - } - log.warn("Fetch: AbortController API not available."); - } + log.debug("Fetch: Called with URL", options.url); + let cancellation : CancellationError | null = null; + let timeouted = false; + const sendingTime = performance.now(); + const abortController: AbortController | null = + !isNullOrUndefined(_AbortController) ? new _AbortController() : + null; + + /** + * Abort current fetchRequest by triggering AbortController signal. + * @returns {void} + */ + function abortFetch(): void { + if (isNullOrUndefined(abortController)) { + log.warn("Fetch: AbortController API not available."); + return; } + abortController.abort(); + } - const requestTimeout = isNullOrUndefined(options.timeout) ? - DEFAULT_REQUEST_TIMEOUT : - options.timeout; - const timeout = window.setTimeout(() => { - timeouted = true; - abortRequest(); - }, requestTimeout); - - fetch(options.url, - { headers, - method: "GET", - signal: !isNullOrUndefined(abortController) ? abortController.signal : - undefined } - ).then((response) => { - if (!isNullOrUndefined(timeout)) { - clearTimeout(timeout); - } - if (response.status >= 300) { - log.warn("Fetch: Request HTTP Error", response); - obs.error(new RequestError(response.url, - response.status, - NetworkErrorTypes.ERROR_HTTP_CODE)); - return undefined; - } - - if (isNullOrUndefined(response.body)) { - obs.error(new RequestError(response.url, - response.status, - NetworkErrorTypes.PARSE_ERROR)); - return undefined; - } + const requestTimeout = isNullOrUndefined(options.timeout) ? + DEFAULT_REQUEST_TIMEOUT : + options.timeout; + const timeout = window.setTimeout(() => { + timeouted = true; + abortFetch(); + }, requestTimeout); + + const deregisterCancelLstnr = options.cancelSignal + .register(function abortRequest(err : CancellationError) { + cancellation = err; + abortFetch(); + }); - const contentLengthHeader = response.headers.get("Content-Length"); - const contentLength = !isNullOrUndefined(contentLengthHeader) && - !isNaN(+contentLengthHeader) ? +contentLengthHeader : - undefined; - const reader = response.body.getReader(); - let size = 0; + return fetch(options.url, + { headers, + method: "GET", + signal: !isNullOrUndefined(abortController) ? abortController.signal : + undefined } + ).then((response : Response) : PPromise => { + if (!isNullOrUndefined(timeout)) { + clearTimeout(timeout); + } + if (response.status >= 300) { + log.warn("Fetch: Request HTTP Error", response); + throw new RequestError(response.url, + response.status, + NetworkErrorTypes.ERROR_HTTP_CODE); + } - return readBufferAndSendEvents(); + if (isNullOrUndefined(response.body)) { + throw new RequestError(response.url, + response.status, + NetworkErrorTypes.PARSE_ERROR); + } - async function readBufferAndSendEvents() : Promise { - const data = await reader.read(); - - if (!data.done && !isNullOrUndefined(data.value)) { - size += data.value.byteLength; - const currentTime = performance.now(); - const dataChunk = { type: "data-chunk" as const, - value: { url: response.url, - currentTime, - duration: currentTime - sendingTime, - sendingTime, - chunkSize: data.value.byteLength, - chunk: data.value.buffer, - size, - totalSize: contentLength } }; - obs.next(dataChunk); - return readBufferAndSendEvents(); - } else if (data.done) { - const receivedTime = performance.now(); - const duration = receivedTime - sendingTime; - isDone = true; - obs.next({ type: "data-complete" as const, - value: { duration, - receivedTime, - sendingTime, - size, - status: response.status, - url: response.url } }); - obs.complete(); - } - } - }).catch((err : unknown) => { - if (hasAborted) { - log.debug("Fetch: Request aborted."); - return; + const contentLengthHeader = response.headers.get("Content-Length"); + const contentLength = !isNullOrUndefined(contentLengthHeader) && + !isNaN(+contentLengthHeader) ? +contentLengthHeader : + undefined; + const reader = response.body.getReader(); + let size = 0; + + return readBufferAndSendEvents(); + + async function readBufferAndSendEvents() : PPromise { + const data = await reader.read(); + + if (!data.done && !isNullOrUndefined(data.value)) { + size += data.value.byteLength; + const currentTime = performance.now(); + const dataInfo = { url: response.url, + currentTime, + duration: currentTime - sendingTime, + sendingTime, + chunkSize: data.value.byteLength, + chunk: data.value.buffer, + size, + totalSize: contentLength }; + options.onData(dataInfo); + return readBufferAndSendEvents(); + } else if (data.done) { + deregisterCancelLstnr(); + const receivedTime = performance.now(); + const duration = receivedTime - sendingTime; + return { duration, + receivedTime, + sendingTime, + size, + status: response.status, + url: response.url }; } - if (timeouted) { - log.warn("Fetch: Request timeouted."); - obs.error(new RequestError(options.url, - 0, - NetworkErrorTypes.TIMEOUT)); - return; - } - log.warn("Fetch: Request Error", err instanceof Error ? - err.toString() : - ""); - obs.error(new RequestError(options.url, - 0, - NetworkErrorTypes.ERROR_EVENT)); - return; - }); - - return () => { - hasAborted = true; - abortRequest(); - }; + return readBufferAndSendEvents(); + } + }).catch((err : unknown) => { + if (cancellation !== null) { + throw cancellation; + } + deregisterCancelLstnr(); + if (timeouted) { + log.warn("Fetch: Request timeouted."); + throw new RequestError(options.url, 0, NetworkErrorTypes.TIMEOUT); + } else if (err instanceof RequestError) { + throw err; + } + log.warn("Fetch: Request Error", err instanceof Error ? err.toString() : + ""); + throw new RequestError(options.url, 0, NetworkErrorTypes.ERROR_EVENT); }); } @@ -220,5 +247,3 @@ export function fetchIsSupported() : boolean { !isNullOrUndefined(_AbortController) && !isNullOrUndefined(_Headers)); } - -export default fetchRequest; diff --git a/src/utils/request/index.ts b/src/utils/request/index.ts index 0afdc7d28d..456bb64d65 100644 --- a/src/utils/request/index.ts +++ b/src/utils/request/index.ts @@ -16,12 +16,12 @@ import fetchRequest, { fetchIsSupported, - IDataChunk, - IDataComplete, + IFetchedDataObject, + IFetchedStreamComplete, } from "./fetch"; import xhr, { IRequestOptions, - IRequestProgress, + IProgressInfo, IRequestResponse, } from "./xhr"; @@ -29,10 +29,10 @@ export default xhr; export { fetchIsSupported, fetchRequest, - IDataChunk, - IDataComplete, + IFetchedDataObject, + IFetchedStreamComplete, + IProgressInfo, IRequestOptions, - IRequestProgress, IRequestResponse, xhr, }; diff --git a/src/utils/request/xhr.ts b/src/utils/request/xhr.ts index 9b712fee44..3fe98b143f 100644 --- a/src/utils/request/xhr.ts +++ b/src/utils/request/xhr.ts @@ -14,57 +14,19 @@ * limitations under the License. */ -import { Observable } from "rxjs"; import config from "../../config"; import { RequestError } from "../../errors"; import isNonEmptyString from "../is_non_empty_string"; import isNullOrUndefined from "../is_null_or_undefined"; +import { + CancellationError, + CancellationSignal, +} from "../task_canceller"; const { DEFAULT_REQUEST_TIMEOUT } = config; const DEFAULT_RESPONSE_TYPE : XMLHttpRequestResponseType = "json"; -// Interface for "progress" events -export interface IRequestProgress { type : "progress"; - value : { currentTime : number; - duration : number; - size : number; - sendingTime : number; - url : string; - totalSize? : number; }; -} - -// Interface for "response" events -export interface IRequestResponse { type : "data-loaded"; - value : { duration : number; - receivedTime : number; - responseData : T; - responseType : U; - sendingTime : number; - size : number; - status : number; - url : string; }; } - -// Arguments for the "request" utils -export interface IRequestOptions { url : string; - headers? : { [ header: string ] : string } | - null; - responseType? : T; - timeout? : number; - sendProgressEvents? : U; } - -/** - * @param {string} data - * @returns {Object|null} - */ -function toJSONForIE(data : string) : unknown|null { - try { - return JSON.parse(data); - } catch (e) { - return null; - } -} - /** * # request function * @@ -146,63 +108,27 @@ function toJSONForIE(data : string) : unknown|null { * @param {Object} options * @returns {Observable} */ - -// overloading to the max -function request(options : IRequestOptions< undefined | null | "" | "text", - false | undefined>) -: Observable>; -function request(options : IRequestOptions< undefined | null | "" | "text", - true >) -: Observable | - IRequestProgress >; - -function request(options : IRequestOptions< "arraybuffer", - false | undefined>) -: Observable>; -function request(options : IRequestOptions<"arraybuffer", true>) -: Observable | - IRequestProgress >; - -function request(options : IRequestOptions< "document", - false | undefined >) -: Observable>; -function request(options : IRequestOptions< "document", - true >) -: Observable | - IRequestProgress >; - -function request(options : IRequestOptions< "json", - false | undefined >) - // eslint-disable-next-line @typescript-eslint/ban-types -: Observable>; -function request(options : IRequestOptions< "json", true >) - // eslint-disable-next-line @typescript-eslint/ban-types -: Observable | - IRequestProgress >; - -function request(options : IRequestOptions< "blob", - false|undefined >) -: Observable>; -function request(options : IRequestOptions<"blob", true>) -: Observable | - IRequestProgress >; - -function request( - options : IRequestOptions< XMLHttpRequestResponseType | null | undefined, - false | undefined >) -: Observable>; -function request( - options : IRequestOptions< XMLHttpRequestResponseType | null | undefined, - true >) -: Observable | - IRequestProgress ->; -function request( - options : IRequestOptions< XMLHttpRequestResponseType | null | undefined, - boolean | undefined > -) : Observable | - IRequestProgress -> { +export default function request( + options : IRequestOptions< undefined | null | "" | "text" > +) : Promise>; +export default function request( + options : IRequestOptions< "arraybuffer" > +) : Promise>; +export default function request( + options : IRequestOptions< "document" > +) : Promise>; +export default function request( + options : IRequestOptions< "json" > +) +// eslint-disable-next-line @typescript-eslint/ban-types +: Promise>; +export default function request( + options : IRequestOptions< "blob" >, +) +: Promise>; +export default function request( + options : IRequestOptions< XMLHttpRequestResponseType | null | undefined > +) : Promise> { const requestOptions = { url: options.url, headers: options.headers, @@ -212,7 +138,8 @@ function request( options.timeout, }; - return new Observable((obs) => { + return new Promise((resolve, reject) => { + const { onProgress, cancelSignal } = options; const { url, headers, responseType, @@ -241,29 +168,53 @@ function request( const sendingTime = performance.now(); + // Handle request cancellation + let deregisterCancellationListener : (() => void) | null = null; + if (cancelSignal !== undefined) { + deregisterCancellationListener = cancelSignal + .register(function abortRequest(err : CancellationError) { + if (!isNullOrUndefined(xhr) && xhr.readyState !== 4) { + xhr.abort(); + } + reject(err); + }); + + if (cancelSignal.isCancelled) { + return; + } + } + xhr.onerror = function onXHRError() { - obs.error(new RequestError(url, xhr.status, "ERROR_EVENT", xhr)); + if (deregisterCancellationListener !== null) { + deregisterCancellationListener(); + } + reject(new RequestError(url, xhr.status, "ERROR_EVENT", xhr)); }; xhr.ontimeout = function onXHRTimeout() { - obs.error(new RequestError(url, xhr.status, "TIMEOUT", xhr)); + if (deregisterCancellationListener !== null) { + deregisterCancellationListener(); + } + reject(new RequestError(url, xhr.status, "TIMEOUT", xhr)); }; - if (options.sendProgressEvents === true) { + if (onProgress !== undefined) { xhr.onprogress = function onXHRProgress(event) { const currentTime = performance.now(); - obs.next({ type: "progress", - value: { url, - duration: currentTime - sendingTime, - sendingTime, - currentTime, - size: event.loaded, - totalSize: event.total } }); + onProgress({ url, + duration: currentTime - sendingTime, + sendingTime, + currentTime, + size: event.loaded, + totalSize: event.total }); }; } xhr.onload = function onXHRLoad(event : ProgressEvent) { if (xhr.readyState === 4) { + if (deregisterCancellationListener !== null) { + deregisterCancellationListener(); + } if (xhr.status >= 200 && xhr.status < 300) { const receivedTime = performance.now(); const totalSize = xhr.response instanceof @@ -287,34 +238,97 @@ function request( } if (isNullOrUndefined(responseData)) { - obs.error(new RequestError(url, xhr.status, "PARSE_ERROR", xhr)); + reject(new RequestError(url, xhr.status, "PARSE_ERROR", xhr)); return; } - obs.next({ type: "data-loaded", - value: { status, - url: _url, - responseType: loadedResponseType, - sendingTime, - receivedTime, - duration: receivedTime - sendingTime, - size: totalSize, - responseData } }); - obs.complete(); + resolve({ status, + url: _url, + responseType: loadedResponseType, + sendingTime, + receivedTime, + duration: receivedTime - sendingTime, + size: totalSize, + responseData }); } else { - obs.error(new RequestError(url, xhr.status, "ERROR_HTTP_CODE", xhr)); + reject(new RequestError(url, xhr.status, "ERROR_HTTP_CODE", xhr)); } } }; xhr.send(); - return () => { - if (!isNullOrUndefined(xhr) && xhr.readyState !== 4) { - xhr.abort(); - } - }; }); } -export default request; +/** + * @param {string} data + * @returns {Object|null} + */ +function toJSONForIE(data : string) : unknown|null { + try { + return JSON.parse(data); + } catch (e) { + return null; + } +} + +/** Options given to `request` */ +export interface IRequestOptions { + /** URL you want to request. */ + url : string; + /** Dictionary of headers you want to set. `null` or `undefined` for no header. */ + headers? : { [ header: string ] : string } | + null; + /** Wanted format for the response */ + responseType? : ResponseType; + /** + * Optional timeout, in milliseconds, after which we will cancel a request. + * Set to DEFAULT_REQUEST_TIMEOUT by default. + */ + timeout? : number; + /** + * "Cancelation token" used to be able to cancel the request. + * When this token is "cancelled", the request will be aborted and the Promise + * returned by `request` will be rejected. + */ + cancelSignal? : CancellationSignal; + /** + * When defined, this callback will be called on each XHR "progress" event + * with data related to this request's progress. + */ + onProgress? : (info : IProgressInfo) => void; +} + +/** Data emitted by `request`'s Promise when the request succeeded. */ +export interface IRequestResponse { + /** Time taken by the request, in milliseconds. */ + duration : number; + /** Time (relative to the "time origin") at which the request ended. */ + receivedTime : number; + /** Data requested. Its type will depend on the responseType. */ + responseData : T; + /** `responseType` requested, gives an indice on the type of `responseData`. */ + responseType : U; + /** Time (relative to the "time origin") at which the request began. */ + sendingTime : number; + /** Full size of the requested data, in bytes. */ + size : number; + /** HTTP status of the response */ + status : number; + /** + * Actual URL requested. + * Can be different from the one given to `request` due to a possible + * redirection. + */ + url : string; +} + +export interface IProgressInfo { + currentTime : number; + duration : number; + size : number; + sendingTime : number; + url : string; + totalSize? : number; +} diff --git a/src/utils/rx-from_cancellable_promise.ts b/src/utils/rx-from_cancellable_promise.ts new file mode 100644 index 0000000000..e2731d5b10 --- /dev/null +++ b/src/utils/rx-from_cancellable_promise.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 PPromise from "pinkie"; +import { Observable } from "rxjs"; +import TaskCanceller from "./task_canceller"; + +/** + * Transform a Promise that can be cancelled (through the usage of a + * `TaskCanceller`) to an Observable, while keeping the cancellation logic + * between both in sync. + * + * @example + * ```js + * const canceller = new TaskCanceller(); + * fromCancellablePromise( + * canceller, + * () => doSomeCancellableTasks(canceller.signal) + * ).subscribe( + * (i) => console.log("Emitted: ", i); + * (e) => console.log("Error: ", e); + * () => console.log("Complete.") + * ); + * ``` + * @param {Object} canceller + * @param {Function} fn + * @returns {Observable} + */ +export default function fromCancellablePromise( + canceller : TaskCanceller, + fn : () => PPromise +) : Observable { + return new Observable((obs) => { + let isUnsubscribedFrom = false; + fn().then( + (i) => { + if (isUnsubscribedFrom) { + return; + } + obs.next(i); + obs.complete(); + }, + (err) => { + if (isUnsubscribedFrom) { + return; + } + obs.error(err); + }); + return () => { + isUnsubscribedFrom = true; + canceller.cancel(); + }; + }); +} diff --git a/src/utils/task_canceller.ts b/src/utils/task_canceller.ts new file mode 100644 index 0000000000..53f5f7b0e2 --- /dev/null +++ b/src/utils/task_canceller.ts @@ -0,0 +1,326 @@ +/** + * 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 assert from "./assert"; +import noop from "./noop"; + +/** + * Class facilitating asynchronous task cancellation. + * + * This class can be used to notify some code running an asynchronous task (for + * example, a request) that is should abort what it is doing (for example, abort + * a request when it isn't needed anymore). + * + * To do that, the code which might ask for cancellation have to create a new + * `TaskCanceller`: + * ```js + * const canceller = new TaskCanceller(); + * ``` + * + * And has to provide its associated `CancellationSignal` to the code running + * the asynchronous task: + * ```js + * runAsyncTask(canceller.signal); + * ``` + * + * In the asynchronous task, the signal can be listened to (see documentation + * on `CancellationSignal` for more information): + * ```js + * function runAsyncTask(cancellationSignal) { + * // Let's say this function returns a Promise (this is not mandatory however) + * return Promise((resolve, reject) => { + * // In this example, we'll even catch the case where an asynchronous task + * // was already cancelled before being called. + * // This ensure that no code will run if that's the case. + * if (cancellationSignal.isCancelled) { + * // Here we're rejecting the CancellationError to notify the caller that + * // this error was due to the task being aborted. + * reject(cancellationSignal.cancellationError); + * return; + * } + * + * // Example: + * // performing asynchronous task and registering callbacks on success/failure. + * const myCancellableTask = doSomeAsyncTasks() + * .onFinished(onTaskFinished); + * .onFailed(onTaskFailed); + * + * // Run a callback when/if the corresponding `TaskCanceller` was triggered. + * // Run immediately if the TaskCanceller was already triggered. + * const deregisterSignal = cancellationSignal.register(onCancellation); + * + * // Callback called on cancellation (if this task was cancelled while the + * // cancellationSignal's listener is still registered). + * // The `error` in argument is linked to that cancellation. It is usually + * // expected that the same Error instance is used when rejecting Promises. + * function onCancellation(error : CancellationError) { + * // abort asynchronous task + * myCancellableTask.cancel(); + * + * // In this example, reject the current pending Promise + * reject(CancellationError); + * } + * + * // Callback called after the asynchronous task has finished with success. + * function onTaskFinished() { + * // Stop listening to the cancellationSignal + * deregisterSignal(); + * + * // Resolve the Promise + * resolve(); + * } + * + * // Callback called after the asynchronous task has finished with failure. + * function onTaskFailed(someError : Error) { + * // Stop listening to the cancellationSignal + * deregisterSignal(); + * + * // Resolve the Promise + * reject(error); + * } + * }); + * } + * ``` + * + * The code asking for cancellation can then trigger a cancellation at any time + * (even before the signal was given) and listen to possible CancellationErrors + * to know when it was cancelled. + * ```js + * const canceller = new TaskCanceller(); + * + * runAsyncTask(canceller.signal) + * .then(() => { console.log("Task succeeded!"); ) + * .catch((err) => { + * if (TaskCanceller.isCancellationError(err)) { + * console.log("Task cancelled!"); + * } else { + * console.log("Task failed:", err); + * } + * }); + * canceller.cancel(); // Cancel the task, calling registered callbacks + * ``` + * @class TaskCanceller + */ +export default class TaskCanceller { + /** + * `CancellationSignal` that can be given to an async task, so it can be + * notified that it should be aborted when this `TaskCanceller` is triggered + * (through its `cancel` method). + */ + public signal : CancellationSignal; + /** + * `true` if this `TaskCanceller` has already been triggered. + * `false` otherwise. + */ + public isUsed : boolean; + /** + * @private + * Internal function called when the `TaskCanceller` is triggered`. + */ + private _trigger : (error : CancellationError) => void; + + /** + * Creates a new `TaskCanceller`, with its own `CancellationSignal` created + * as its `signal` provide. + * You can then pass this property to async task you wish to be cancellable. + */ + constructor() { + const [trigger, register] = createCancellationFunctions(); + this.isUsed = false; + this._trigger = trigger; + this.signal = new CancellationSignal(register); + } + + /** + * "Trigger" the `TaskCanceller`, notify through its associated + * `CancellationSignal` (its `signal` property) that a task should be aborted. + * + * Once called the `TaskCanceller` is permanently triggered. + * + * An optional CancellationError can be given in argument for when this + * cancellation is actually triggered as a chain reaction from a previous + * cancellation. + * @param {Error} [originError] + */ + public cancel(originError? : CancellationError) : void { + if (this.isUsed) { + return ; + } + this.isUsed = true; + const cancellationError = originError ?? new CancellationError(); + this._trigger(cancellationError); + } + + /** + * Check that the `error` in argument is a `CancellationError`, most likely + * meaning that the linked error is due to a task aborted via a + * `CancellationSignal`. + * @param {*} error + * @returns {boolean} + */ + static isCancellationError(error : unknown) : boolean { + return error instanceof CancellationError; + } +} + +/** + * Class associated to a TaskCanceller allowing to be notified when a task + * needs to be aborted. + * @class + */ +export class CancellationSignal { + /** + * True when the associated `TaskCanceller` was already triggered, meaning + * that the current task needs to be aborted. + */ + public isCancelled : boolean; + /** + * Error associated to the cancellation. + * Can be used to notify to a caller that this task was aborted (for example + * by rejecting it through the Promise associated to that task). + * + * Always set if `isCancelled` is equal to `true`. + */ + public cancellationError : CancellationError | null; + + /** + * @private + * Functions called when the corresponding `TaskCanceller` is triggered. + * Those should perform all logic allowing to cancel the current task(s) + * which depend on this CancellationSignal. + */ + private _listeners : Array<(error : CancellationError) => void>; + + /** + * Creates a new CancellationSignal. + * /!\ Note: Only a `TaskCanceller` is supposed to be able to create one. + * @param {Function} registerToSource - Function called when the task is + * cancelled. + */ + constructor(registerToSource : (listener: ICancellationListener) => void) { + this.isCancelled = false; + this.cancellationError = null; + this._listeners = []; + + registerToSource((cancellationError : CancellationError) : void => { + this.cancellationError = cancellationError; + this.isCancelled = true; + while (this._listeners.length > 0) { + const listener = this._listeners.splice(this._listeners.length - 1, 1)[0]; + listener(cancellationError); + } + }); + } + + /** + * Registers a function that will be called when/if the current task is + * cancelled. + * + * Multiple calls to `register` can be performed to register multiple + * callbacks on cancellation associated to the same `CancellationSignal`. + * + * @param {Function} fn - This function should perform all logic allowing to + * abort everything the task is doing. + * + * It takes in argument the `CancellationError` which was created when the + * task was aborted. + * You can use this error to notify callers that the task has been aborted, + * for example through a rejected Promise. + * + * @return {Function} - Removes that cancellation listener. You can call this + * once you don't want the callback to be triggered anymore (e.g. after the + * task succeeded or failed). + * You don't need to call that function when cancellation has already been + * performed. + */ + public register(fn : ICancellationListener) : () => void { + if (this.isCancelled) { + assert(this.cancellationError !== null); + fn(this.cancellationError); + } + this._listeners.push(fn); + return () => this.deregister(fn); + } + + /** + * De-register a function registered through the `register` function. + * Do nothing if that function wasn't registered. + * + * You can call this method when using the return value of `register` is not + * practical. + * @param {Function} fn + */ + public deregister(fn : ICancellationListener) : void { + if (this.isCancelled) { + return; + } + for (let i = 0; i < this._listeners.length; i++) { + if (this._listeners[i] === fn) { + this._listeners.splice(i, 1); + return; + } + } + } +} + +/** + * Helper type allowing a `CancellationSignal` to register to a cancellation asked + * by a `TaskCanceller`. + */ +export type ICancellationListener = (error : CancellationError) => void; + +/** + * Error created when a task is cancelled through the TaskCanceller. + * + * @class CancellationError + * @extends Error + */ +export class CancellationError extends Error { + public readonly name : "CancellationError"; + public readonly message : string; + + /** + * @param {string} message + */ + constructor() { + super(); + // @see https://stackoverflow.com/questions/41102060/typescript-extending-error-class + Object.setPrototypeOf(this, CancellationError.prototype); + + this.name = "CancellationError"; + this.message = "This task was cancelled."; + } +} + +/** + * Helper function allowing communication between a `TaskCanceller` and a + * `CancellationSignal`. + * @returns {Array.} + */ +function createCancellationFunctions() : [ + (error : CancellationError) => void, + (newListener : ICancellationListener) => void +] { + let listener : (error : CancellationError) => void = noop; + return [ + function trigger(error : CancellationError) { + listener(error); + }, + function register(newListener : ICancellationListener) { + listener = newListener; + }, + ]; +} diff --git a/tests/integration/scenarios/initial_playback.js b/tests/integration/scenarios/initial_playback.js index c6d2d4b7e5..1238ec4ce9 100644 --- a/tests/integration/scenarios/initial_playback.js +++ b/tests/integration/scenarios/initial_playback.js @@ -224,16 +224,16 @@ describe("basic playback use cases: non-linear DASH SegmentTimeline", function ( url: manifestInfos.url, }); - await sleep(1); + await sleep(10); expect(xhrMock.getLockedXHR().length).to.equal(1); // Manifest await xhrMock.flush(); - await sleep(1); + await sleep(10); expect(xhrMock.getLockedXHR().length).to.equal(2); // init segments await xhrMock.flush(); - await sleep(1); + await sleep(10); expect(xhrMock.getLockedXHR().length).to.equal(2); // first two segments await xhrMock.flush(); // first two segments - await sleep(1); + await sleep(10); expect(xhrMock.getLockedXHR().length).to.equal(0); // nada expect(player.getVideoLoadedTime()).to.be.above(4); expect(player.getVideoLoadedTime()).to.be.below(5); diff --git a/tests/integration/scenarios/manifest_error.js b/tests/integration/scenarios/manifest_error.js index fc28c9f7a0..e8681dcb3c 100644 --- a/tests/integration/scenarios/manifest_error.js +++ b/tests/integration/scenarios/manifest_error.js @@ -58,30 +58,35 @@ describe("manifest error management", function () { await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); clock.tick(5000); expect(player.getError()).to.equal(null); await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); clock.tick(5000); expect(player.getError()).to.equal(null); await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); clock.tick(5000); expect(player.getError()).to.equal(null); await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); clock.tick(5000); expect(player.getError()).to.equal(null); await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); clock.restore(); @@ -109,6 +114,7 @@ describe("manifest error management", function () { await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); fakeServer.restore(); clock.tick(5000); @@ -138,12 +144,14 @@ describe("manifest error management", function () { await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); clock.tick(5000); expect(player.getError()).to.equal(null); await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); fakeServer.restore(); clock.tick(5000); @@ -173,18 +181,21 @@ describe("manifest error management", function () { await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); clock.tick(5000); expect(player.getError()).to.equal(null); await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); clock.tick(5000); expect(player.getError()).to.equal(null); await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); fakeServer.restore(); clock.tick(5000); @@ -214,22 +225,26 @@ describe("manifest error management", function () { await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); clock.tick(5000); await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); clock.tick(5000); expect(player.getError()).to.equal(null); await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); clock.tick(5000); expect(player.getError()).to.equal(null); await sleepWithoutSinonStub(50); fakeServer.respond(); + await sleepWithoutSinonStub(0); fakeServer.restore(); clock.tick(5000); diff --git a/tests/integration/utils/launch_tests_for_content.js b/tests/integration/utils/launch_tests_for_content.js index 98ece76b95..e80efecae0 100644 --- a/tests/integration/utils/launch_tests_for_content.js +++ b/tests/integration/utils/launch_tests_for_content.js @@ -157,7 +157,7 @@ export default function launchTestsForContent(manifestInfos) { transport, transportOptions: { initialManifest } }); - await sleep(15); + await sleep(100); expect(xhrMock.getLockedXHR().length).to.be.at.least(1); expect(xhrMock.getLockedXHR()[0].url).not.to.equal(manifestInfos.url); });