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 c767b15e25..ed52f7f29a 100644 --- a/src/core/fetchers/manifest/manifest_fetcher.ts +++ b/src/core/fetchers/manifest/manifest_fetcher.ts @@ -14,39 +14,33 @@ * 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, } from "../../../errors"; 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 { @@ -105,7 +99,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. @@ -136,66 +130,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); + } + }); } /** @@ -230,57 +279,132 @@ 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", + IManifestFetcherParsedResult> + { + return new Observable(obs => { + const canceller = new TaskCanceller(); + const { sendingTime, receivedTime } = loaded; + const parsingTimeStart = performance.now(); + 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, + scheduleRequest); + if (!isPromise(res)) { + emitManifestAndComplete(res.manifest); + } else { + res + .then(({ manifest }) => emitManifestAndComplete(manifest)) + .catch((err) => emitError(err, true)); + } + } catch (err) { + 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} + */ + function scheduleRequest( + performRequest : () => Promise + ) : Promise { + return tryRequestPromiseWithBackoff(performRequest, + backoffSettings, + canceller.signal) + .catch(err => { + throw errorSelector(err); }); - }), - map((parsingEvt) => { - if (parsingEvt.type === "warning") { - const formatted = formatError(parsingEvt.value, { - defaultCode: "PIPELINE_PARSE_ERROR", - defaultReason: "Unknown error when parsing the Manifest", - }); - return { type: "warning" as const, - value: formatted }; - } + } + + /** + * Handle minor errors encountered by a Manifest parser. + * @param {Array.} warnings + */ + function onWarnings(warnings : Error[]) : void { + for (const warning of warnings) { + emitError(warning, false); + } + } - // 2 - send response - const parsingTime = performance.now() - parsingTimeStart; - return { type: "parsed" as const, - manifest: parsingEvt.value.manifest, + /** + * Emit a formatted "parsed" event through `obs`. + * To call once the Manifest has been parsed. + * @param {Object} manifest + */ + function emitManifestAndComplete(manifest : Manifest) : void { + const parsingTime = performance.now() - parsingTimeStart; + 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 }; + } +} + +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..9adb6e96f3 100644 --- a/src/core/fetchers/segment/segment_fetcher.ts +++ b/src/core/fetchers/segment/segment_fetcher.ts @@ -15,29 +15,28 @@ */ 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 from "../../../utils/task_canceller"; import { IABRMetricsEvent, IABRRequestBeginEvent, @@ -45,56 +44,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 +64,285 @@ 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(); + } - 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} + * @returns {Observable} + */ + function callLoaderWithUrl(url : string | null) { + return loadSegment(url, content, canceller.signal, 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 ab56e72a1e..b859b2e0fa 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 { isKnownError, @@ -32,7 +22,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. @@ -67,27 +61,35 @@ function isOfflineRequestError(error : RequestError) : boolean { isOffline(); } -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. @@ -105,12 +107,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 @@ -118,34 +123,42 @@ 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) => 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); @@ -162,71 +175,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; - } - - // 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 })); - } - - const currentError = getRequestErrorType(error); - const maxRetry = currentError === REQUEST_ERROR_TYPES.Offline ? maxRetryOffline : - maxRetryRegular; + ) : Promise { + try { + const res = await performRequest(url); + return res; + } catch (error : unknown) { + if (TaskCanceller.isCancellationError(error)) { + throw error; + } - if (currentError !== lastError) { - retryCount = 0; - lastError = currentError; + if (!shouldRetry(error)) { + // ban this URL + if (urlsToTry.length <= 1) { // This was the last one, throw + throw error; } - 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 })); + // 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); + } - // Here, we were using the last element of the `urlsToTry` array. - // Increment counter and restart with the first URL + const currentError = getRequestErrorType(error); + const maxRetry = currentError === REQUEST_ERROR_TYPES.Offline ? maxRetryOffline : + maxRetryRegular; - retryCount++; - if (retryCount > maxRetry) { - throw error; + if (currentError !== lastError) { + retryCount = 0; + lastError = currentError; + } + + if (index < urlsToTry.length - 1) { // there is still URLs to test + const newIndex = index + 1; + onRetry(error); + if (cancellationSignal.isCancelled) { + throw cancellationSignal.cancellationError; } - 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 })); - }) - ); + return tryURLsRecursively(urlsToTry[newIndex], newIndex); + } + + // 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]; + 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/period/period_stream.ts b/src/core/stream/period/period_stream.ts index c914408bfe..c54a398f57 100644 --- a/src/core/stream/period/period_stream.ts +++ b/src/core/stream/period/period_stream.ts @@ -18,6 +18,7 @@ import { BehaviorSubject, concat as observableConcat, EMPTY, + from as observableFrom, merge as observableMerge, Observable, of as observableOf, @@ -166,10 +167,10 @@ export default function PeriodStream({ if (SegmentBuffersStore.isNative(bufferType)) { return reloadAfterSwitch(period, clock$, relativePosAfterSwitch); } - cleanBuffer$ = segmentBufferStatus.value - .removeBuffer(period.start, - period.end == null ? Infinity : - period.end); + cleanBuffer$ = observableFrom( + segmentBufferStatus.value.removeBuffer(period.start, + period.end == null ? Infinity : + period.end)); } else { if (segmentBufferStatus.type === "uninitialized") { segmentBuffersStore.disableSegmentBuffer(bufferType); @@ -209,10 +210,11 @@ export default function PeriodStream({ return reloadAfterSwitch(period, clock$, relativePosAfterSwitch); } - const cleanBuffer$ = strategy.type === "clean-buffer" ? - observableConcat(...strategy.value.map(({ start, end }) => - segmentBuffer.removeBuffer(start, end)) - ).pipe(ignoreElements()) : EMPTY; + const cleanBuffer$ = strategy.type !== "clean-buffer" ? + EMPTY : + observableConcat(...strategy.value.map(({ start, end }) => { + return observableFrom(segmentBuffer.removeBuffer(start, end)); + })).pipe(ignoreElements()); const bufferGarbageCollector$ = garbageCollectors.get(segmentBuffer); const adaptationStream$ = createAdaptationStream(adaptation, segmentBuffer); diff --git a/src/core/stream/representation/representation_stream.ts b/src/core/stream/representation/representation_stream.ts index 03ed64e63a..8b8f8fe6a1 100644 --- a/src/core/stream/representation/representation_stream.ts +++ b/src/core/stream/representation/representation_stream.ts @@ -29,6 +29,7 @@ import { concat as observableConcat, defer as observableDefer, EMPTY, + from as observableFrom, merge as observableMerge, Observable, of as observableOf, @@ -201,14 +202,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 +220,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 +241,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 +399,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 +414,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 +454,8 @@ export default function RepresentationStream({ * @returns {Observable} */ function onLoaderEvent( - evt : ISegmentLoadingEvent - ) : Observable | + evt : ISegmentLoadingEvent + ) : Observable | ISegmentFetcherWarning | IEncryptionDataEncounteredEvent | IInbandEventsEvent | @@ -494,8 +498,8 @@ export default function RepresentationStream({ * @returns {Observable} */ function onParsedChunk( - evt : IParsedSegmentEvent - ) : Observable | + evt : IParsedSegmentEvent + ) : Observable | IEncryptionDataEncounteredEvent | IInbandEventsEvent | IStreamNeedsManifestRefresh | @@ -514,17 +518,19 @@ export default function RepresentationStream({ observableOf(...allEncryptionData.map(p => EVENTS.encryptionDataEncountered(p))) : EMPTY; - const pushEvent$ = pushInitSegment({ clock$, - content, - segment: evt.segment, - segmentData: parsed.initializationData, - segmentBuffer }); - return observableMerge(initEncEvt$, pushEvent$); + const pushInit$ = parsed.initializationData === null ? + EMPTY : + observableFrom(pushInitSegment({ clock$, + content, + segmentData: parsed.initializationData, + segment: evt.segment, + segmentBuffer })); + return observableMerge(initEncEvt$, pushInit$); } else { const initSegmentData = initSegmentObject?.initializationData ?? null; - const { inbandEvents, - needsManifestRefresh } = parsed; + const { inbandEvents, needsManifestRefresh, chunkData } = parsed; + const manifestRefresh$ = needsManifestRefresh === true ? observableOf(EVENTS.needsManifestRefresh()) : EMPTY; @@ -533,14 +539,19 @@ export default function RepresentationStream({ observableOf({ type: "inband-events" as const, value: inbandEvents }) : EMPTY; + + const pushSegment$ = chunkData === null ? + EMPTY : + observableFrom(pushMediaSegment({ clock$, + content, + initSegmentData, + parsedSegment: parsed, + segment: evt.segment, + segmentBuffer })); + return observableConcat(manifestRefresh$, inbandEvents$, - pushMediaSegment({ clock$, - content, - initSegmentData, - parsedSegment: parsed, - segment: evt.segment, - segmentBuffer })); + pushSegment$); } } } diff --git a/src/experimental/tools/createMetaplaylist/get_duration_from_manifest.ts b/src/experimental/tools/createMetaplaylist/get_duration_from_manifest.ts index a7b7319a8e..dc108ce5c7 100644 --- a/src/experimental/tools/createMetaplaylist/get_duration_from_manifest.ts +++ b/src/experimental/tools/createMetaplaylist/get_duration_from_manifest.ts @@ -14,11 +14,16 @@ * limitations under the License. */ -import { Observable, throwError } from "rxjs"; +import { + Observable, + throwError, +} from "rxjs"; 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)?/; @@ -79,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 5f5b479f50..7d28e2298d 100644 --- a/src/transports/dash/add_segment_integrity_checks_to_loader.ts +++ b/src/transports/dash/add_segment_integrity_checks_to_loader.ts @@ -14,38 +14,79 @@ * 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 isWEBMEmbeddedTrack from "../utils/is_webm_embedded_track"; /** * 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" && - !isWEBMEmbeddedTrack(content.representation)) - { - 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) || + isWEBMEmbeddedTrack(content.representation)) + { + 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 b378e34af4..d3dbe12aca 100644 --- a/src/transports/dash/image_pipelines.ts +++ b/src/transports/dash/image_pipelines.ts @@ -14,37 +14,50 @@ * 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"; /** - * @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 })); } /** @@ -52,13 +65,13 @@ export function imageLoader( * @returns {Observable} */ 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 82c480f136..7b30c03315 100644 --- a/src/transports/dash/manifest_parser.ts +++ b/src/transports/dash/manifest_parser.ts @@ -14,31 +14,21 @@ * limitations under the License. */ -import { - combineLatest as observableCombineLatest, - concat as observableConcat, - Observable, - of as observableOf, -} from "rxjs"; -import { - filter, - map, - mergeMap, -} from "rxjs/operators"; +import PPromise from "pinkie"; import Manifest from "../../manifest"; import dashManifestParser, { IMPDParserResponse, } from "../../parsers/manifest/dash"; import objectAssign from "../../utils/object_assign"; import request from "../../utils/request"; +import TaskCanceller from "../../utils/task_canceller"; import { - ILoaderDataLoadedValue, - IManifestParserArguments, - IManifestParserResponseEvent, - IManifestParserWarningEvent, + IManifestParserOptions, + IManifestParserRequestScheduler, + IManifestParserResult, + IRequestedData, ITransportOptions, } from "../types"; -import returnParsedManifest from "../utils/return_parsed_manifest"; /** * Request external "xlink" ressource from a MPD. @@ -47,21 +37,19 @@ import returnParsedManifest from "../utils/return_parsed_manifest"; */ function requestStringResource( url : string -) : Observable< ILoaderDataLoadedValue< string > > { +) : Promise< IRequestedData< string > > { return request({ url, - responseType: "text" }) - .pipe(filter((e) => e.type === "data-loaded"), - map((e) => e.value)); + responseType: "text" }); } -/** - * @param {Object} options - * @returns {Function} - */ export default function generateManifestParser( options : ITransportOptions -) : (x : IManifestParserArguments) => Observable +) : ( + manifestData : IRequestedData, + parserOptions : IManifestParserOptions, + onWarnings : (warnings : Error[]) => void, + scheduleRequest : IManifestParserRequestScheduler + ) => IManifestParserResult | Promise { const { aggressiveMode, referenceDateTime } = options; @@ -69,21 +57,23 @@ export default function generateManifestParser( options.serverSyncInfos.serverTimestamp - options.serverSyncInfos.clientTime : undefined; return function manifestParser( - args : IManifestParserArguments - ) : Observable { - const { response, scheduleRequest } = args; - const argClockOffset = args.externalClockOffset; - const loaderURL = args.url; - const url = response.url ?? loaderURL; - const data = typeof response.responseData === "string" ? - new DOMParser().parseFromString(response.responseData, + manifestData : IRequestedData, + parserOptions : IManifestParserOptions, + onWarnings : (warnings : Error[]) => void, + scheduleRequest : IManifestParserRequestScheduler + ) : IManifestParserResult | Promise { + const argClockOffset = parserOptions.externalClockOffset; + const url = manifestData.url ?? parserOptions.originalUrl; + const data = typeof manifestData.responseData === "string" ? + new DOMParser().parseFromString(manifestData.responseData, "text/xml") : // TODO find a way to check if Document? - response.responseData as Document; + manifestData.responseData as Document; const externalClockOffset = serverTimeOffset ?? argClockOffset; - const unsafelyBaseOnPreviousManifest = args.unsafeMode ? args.previousManifest : - null; + const unsafelyBaseOnPreviousManifest = parserOptions.unsafeMode ? + parserOptions.previousManifest : + null; const parsedManifest = dashManifestParser(data, { aggressiveMode: aggressiveMode === true, unsafelyBaseOnPreviousManifest, @@ -94,35 +84,36 @@ export default function generateManifestParser( function loadExternalResources( parserResponse : IMPDParserResponse - ) : 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); + } + const manifest = new Manifest(parserResponse.value.parsed, options); + return { manifest, url }; } const { ressources, continue: continueParsing } = parserResponse.value; - const externalResources$ = ressources - .map(resource => scheduleRequest(() => requestStringResource(resource))); + const externalResources = ressources + .map(resource => { + const canceller = new TaskCanceller(); + return scheduleRequest(() => requestStringResource(resource), canceller); + }); - return observableCombineLatest(externalResources$) - .pipe(mergeMap(loadedResources => { - const resources : Array> = []; - for (let i = 0; i < loadedResources.length; i++) { - const resource = loadedResources[i]; - if (typeof resource.responseData !== "string") { - throw new Error("External DASH resources should only be strings"); - } - // Normally not needed but TypeScript is just dumb here - resources.push(objectAssign(resource, - { responseData: resource.responseData })); + return PPromise.all(externalResources).then(loadedResources => { + const resources : Array> = []; + for (let i = 0; i < loadedResources.length; i++) { + const resource = loadedResources[i]; + if (typeof resource.responseData !== "string") { + throw new Error("External DASH resources should only be strings"); } - return loadExternalResources(continueParsing(resources)); - })); + // Normally not needed but TypeScript is just dumb here + resources.push(objectAssign(resource, + { responseData: resource.responseData })); + } + return loadExternalResources(continueParsing(resources)); + }); } }; } diff --git a/src/transports/dash/pipelines.ts b/src/transports/dash/pipelines.ts index 8ffb788d3c..5795d84907 100644 --- a/src/transports/dash/pipelines.ts +++ b/src/transports/dash/pipelines.ts @@ -45,14 +45,14 @@ 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 52ae77249d..e56c44cbd7 100644 --- a/src/transports/dash/segment_loader.ts +++ b/src/transports/dash/segment_loader.ts @@ -14,22 +14,24 @@ * limitations under the License. */ -import { - Observable, - Observer, - of as observableOf, -} from "rxjs"; -import xhr, { +import PPromise from "pinkie"; +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 isWEBMEmbeddedTrack from "../utils/is_webm_embedded_track"; @@ -37,42 +39,49 @@ 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 isWEBM = isWEBMEmbeddedTrack(args.representation); + const isWEBM = isWEBMEmbeddedTrack(content.representation); if (lowLatencyMode && !isWEBM) { 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 })); } /** @@ -85,7 +94,7 @@ export default function generateSegmentLoader( checkMediaSegmentIntegrity } : { lowLatencyMode: boolean; segmentLoader? : CustomSegmentLoader; checkMediaSegmentIntegrity? : boolean; } -) : ISegmentLoader< Uint8Array | ArrayBuffer | null > { +) : ISegmentLoader { return checkMediaSegmentIntegrity !== true ? segmentLoader : addSegmentIntegrityChecks(segmentLoader); @@ -94,16 +103,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, @@ -114,7 +128,7 @@ export default function generateSegmentLoader( transport: "dash", url }; - return new Observable((obs : ICustomSegmentLoaderObserver) => { + return new Promise((res, rej) => { let hasFinished = false; let hasFallbacked = false; @@ -127,14 +141,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(); + hasFinished = true; + if (hasFallbacked || cancelSignal.isCancelled) { + return; } + cancelSignal.deregister(abortCustomLoader); + res({ resultType: "segment-loaded", + resultData: { responseData: _args.data, + size: _args.size, + duration: _args.duration } }); }; /** @@ -142,10 +157,12 @@ export default function generateSegmentLoader( * @param {*} err - The corresponding error encountered */ const reject = (err = {}) : void => { - if (!hasFallbacked) { - hasFinished = true; - obs.error(err); + hasFinished = true; + if (hasFallbacked || cancelSignal.isCancelled) { + return; } + cancelSignal.deregister(abortCustomLoader); + rej(err); }; const progress = ( @@ -153,11 +170,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 (hasFallbacked || cancelSignal.isCancelled) { + return; } + callbacks.onProgress({ duration: _args.duration, + size: _args.size, + totalSize: _args.totalSize }); }; /** @@ -166,25 +184,29 @@ export default function generateSegmentLoader( */ 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 */ + 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); + + cancelSignal.register(abortCustomLoader); - return () => { - if (!hasFinished && !hasFallbacked && typeof abort === "function") { + /** + * The logic to run when the custom loader is cancelled while pending. + * @param {Error} err + */ + function abortCustomLoader(err : CancellationError) { + if (hasFallbacked) { + return; + } + if (!hasFinished && typeof abort === "function") { abort(); } - }; + rej(err); + } }); } } diff --git a/src/transports/dash/segment_parser.ts b/src/transports/dash/segment_parser.ts index 4852babd2b..83306994eb 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> { - const { period, representation, segment, manifest } = content; - const { data, isChunked } = response; + loadedSegment : { data : ArrayBuffer | Uint8Array | null; + isChunked : boolean; }, + content : ISegmentContext, + initTimescale : number | undefined + ) : ISegmentParserParsedSegment< Uint8Array | ArrayBuffer | null > | + ISegmentParserParsedInitSegment< Uint8Array | ArrayBuffer | null > { + const { manifest, period, representation, segment } = content; + 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 b34f1d40c3..80c8aa2df0 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 isMP4EmbeddedTextTrack from "../utils/is_mp4_embedded_text_track"; @@ -39,52 +42,70 @@ 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 { 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 isMP4Embedded = isMP4EmbeddedTextTrack(args.representation); + const isMP4Embedded = isMP4EmbeddedTextTrack(representation); if (lowLatencyMode && isMP4Embedded) { 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 = isMP4Embedded ? "arraybuffer" : - "text"; - return request({ url, - responseType, - headers: Array.isArray(range) ? - { Range: byteRange(range) } : - null, - sendProgressEvents: true }); + if (isMP4Embedded) { + 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 2b7ffcbda6..0271812ec8 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; @@ -143,16 +133,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) { @@ -186,25 +171,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, representation, segment } = content; - const { data, isChunked } = response; - + const { data, isChunked } = loadedSegment; if (data === null) { // No data, just return an empty placeholder object return segment.isInit ? { segmentType: "init", 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 c7858bb171..24255a4466 100644 --- a/src/transports/local/segment_loader.ts +++ b/src/transports/local/segment_loader.ts @@ -14,31 +14,29 @@ * limitations under the License. */ -import { - Observable, - Observer, -} from "rxjs"; +import PPromise from "pinkie"; import { ILocalManifestInitSegmentLoader, ILocalManifestSegmentLoader, } from "../../parsers/manifest/local"; import isNullOrUndefined from "../../utils/is_null_or_undefined"; +import { CancellationError, CancellationSignal } from "../../utils/task_canceller"; import { - ISegmentLoaderArguments, - ISegmentLoaderDataLoadedEvent, - ISegmentLoaderEvent, + 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) => { let hasFinished = false; /** @@ -51,11 +49,13 @@ function loadInitSegment( duration? : number; }) => { hasFinished = true; - obs.next({ type: "data-loaded", - value: { responseData: _args.data, - size: _args.size, - duration: _args.duration } }); - obs.complete(); + if (!cancelSignal.isCancelled) { + cancelSignal.deregister(abortLoader); + res({ resultType: "segment-loaded", + resultData: { responseData: _args.data, + size: _args.size, + duration: _args.duration } }); + } }; /** @@ -64,31 +64,40 @@ function loadInitSegment( */ const reject = (err? : Error) => { hasFinished = true; - obs.error(err); + if (!cancelSignal.isCancelled) { + cancelSignal.deregister(abortLoader); + rej(err); + } }; const abort = customSegmentLoader({ resolve, reject }); - return () => { + cancelSignal.register(abortLoader); + /** + * The logic to run when this loader is cancelled while pending. + * @param {Error} err + */ + function abortLoader(err : CancellationError) { if (!hasFinished && 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) => { let hasFinished = false; /** @@ -100,12 +109,14 @@ function loadSegment( size? : number; duration? : number; }) => { + cancelSignal.deregister(abortLoader); hasFinished = true; - obs.next({ type: "data-loaded", - value: { responseData: _args.data, - size: _args.size, - duration: _args.duration } }); - obs.complete(); + if (!cancelSignal.isCancelled) { + res({ resultType: "segment-loaded", + resultData: { responseData: _args.data, + size: _args.size, + duration: _args.duration } }); + } }; /** @@ -113,40 +124,58 @@ function loadSegment( * @param {*} err - The corresponding error encountered */ const reject = (err? : Error) => { + cancelSignal.deregister(abortLoader); hasFinished = true; - obs.error(err); + if (!cancelSignal.isCancelled) { + rej(err); + } }; const abort = customSegmentLoader(segment, { resolve, reject }); - return () => { + cancelSignal.register(abortLoader); + /** + * The logic to run when this loader is cancelled while pending. + * @param {Error} err + */ + function abortLoader(err : CancellationError) { if (!hasFinished && 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 42ff4170b5..c06a3ee50a 100644 --- a/src/transports/local/segment_parser.ts +++ b/src/transports/local/segment_parser.ts @@ -22,23 +22,23 @@ import { getTimeCodeScale } from "../../parsers/containers/matroska"; import isNullOrUndefined from "../../utils/is_null_or_undefined"; import takeFirstSet from "../../utils/take_first_set"; import { - ISegmentParserParsedSegment, + ISegmentContext, ISegmentParserParsedInitSegment, - ISegmentParserArguments, + ISegmentParserParsedSegment, } from "../types"; import getISOBMFFTimingInfos from "../utils/get_isobmff_timing_infos"; import isWEBMEmbeddedTrack from "../utils/is_webm_embedded_track"; -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, segment, representation } = 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 9a381f051f..4746c5d9e5 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 { @@ -98,23 +87,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 { @@ -145,21 +129,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, 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..62d2e1b162 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 TaskCanceller, { 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,53 @@ 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, + 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()); + const canceller = new TaskCanceller(); + return scheduleRequest(loadSubManifest, canceller) + .then((data) => + transport.manifest.parseManifest(data, + { ...parserOptions, + originalUrl: resource.url }, + onWarnings, + scheduleRequest)); + function loadSubManifest() { + return transport.manifest.loadManifest(resource.url, canceller.signal); + } }); - 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 +243,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 +280,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 +317,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 +354,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 d010b2c481..7dac5fa456 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, @@ -55,7 +52,6 @@ import { ITransportPipelines, } from "../types"; import checkISOBMFFIntegrity from "../utils/check_isobmff_integrity"; -import returnParsedManifest from "../utils/return_parsed_manifest"; import generateManifestLoader from "../utils/text_manifest_loader"; import extractTimingsInfos, { INextSegmentsInfos, @@ -65,6 +61,7 @@ import generateSegmentLoader from "./segment_loader"; import { extractISML, extractToken, + isMP4EmbeddedTrack, replaceToken, resolveManifest, } from "./utils"; @@ -98,92 +95,104 @@ 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); 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", @@ -238,45 +247,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, @@ -404,27 +426,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", @@ -457,24 +488,13 @@ export default function(options : ITransportOptions) : ITransportPipelines { chunkInfos: { time: 0, duration: Number.MAX_VALUE }, chunkOffset: 0, - 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 7a3c65f6ac..a8d9525ecd 100644 --- a/src/transports/smooth/segment_loader.ts +++ b/src/transports/smooth/segment_loader.ts @@ -14,44 +14,46 @@ * limitations under the License. */ -import { - Observable, - Observer, - of as observableOf, -} from "rxjs"; +import PPromise from "pinkie"; 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) }; } @@ -59,24 +61,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) @@ -125,11 +142,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, @@ -140,10 +157,14 @@ 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) => { let hasFinished = false; let hasFallbacked = false; @@ -156,24 +177,38 @@ const generateSegmentLoader = ( size? : number; duration? : number; }) => { - if (!hasFallbacked) { - hasFinished = true; - obs.next({ type: "data-loaded", - value: { responseData: _args.data, + if (hasFallbacked || cancelSignal.isCancelled) { + return; + } + + cancelSignal.deregister(abortCustomLoader); + hasFinished = true; + 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 } }); }; /** * Callback triggered when the custom segment loader fails * @param {*} err - The corresponding error encountered */ - const reject = (err = {}) => { - if (!hasFallbacked) { - hasFinished = true; - obs.error(err); + const reject = (err : unknown) => { + hasFinished = true; + if (!hasFallbacked && !cancelSignal.isCancelled) { + cancelSignal.deregister(abortCustomLoader); + rej(err); } }; @@ -182,33 +217,42 @@ const generateSegmentLoader = ( size : number; totalSize? : number; } ) => { - if (!hasFallbacked) { - obs.next({ type: "progress", value: { duration: _args.duration, - size: _args.size, - totalSize: _args.totalSize } }); + if (!hasFallbacked && !cancelSignal.isCancelled) { + 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 */ + 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 (hasFallbacked) { + return; + } + 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 2e02b56bcb..3406a6f15f 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,192 @@ 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 {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. + * + * If a request scheduled through `scheduleRequest` rejects with an error + * - 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`. + */ + parseManifest : ( + manifestData : IRequestedData, + parserOptions : IManifestParserOptions, + onWarnings : (warnings : Error[]) => void, + 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,200 +251,16 @@ export type ISegmentParser< ISegmentParserParsedInitSegment | ISegmentParserParsedSegment; -/** Arguments for the loader of the manifest pipeline. */ -export interface IManifestLoaderArguments { - /** - * 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. - */ - url : string | undefined; -} - -/** 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; - /** - * 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). - */ - url : string | null; -} - -/** 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; - /** - * "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. - */ - 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; -} - -/** Form that can take a loaded Manifest once loaded. */ -export type ILoadedManifest = Document | - string | - IMetaPlaylist | - ILocalManifest | - Manifest; - -/** Event emitted by a Manifest loader when the Manifest is fully available. */ -export interface IManifestLoaderDataLoadedEvent { - type : "data-loaded"; - value : ILoaderDataLoadedValue; -} - -/** Event emitted by a segment loader when the data has been fully loaded. */ -export interface ISegmentLoaderDataLoadedEvent { - type : "data-loaded"; - value : ILoaderDataLoadedValue; -} - -/** - * 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 }; -} - -/** - * 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; - }; -} - -/** 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; - }; -} - -/** - * 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; - }; -} - -/** - * 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 IManifestParserOptions { /** * If set, offset to add to `performance.now()` to obtain the current * server's time. */ - externalClockOffset? : number; + 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; - /** - * 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< Document | string > >) => - Observable< ILoaderDataLoadedValue< Document | string > >; /** * If set to `true`, the Manifest parser can perform advanced optimizations * to speed-up the parsing process. Those optimizations might lead to a @@ -355,72 +271,90 @@ export interface IManifestParserArguments { 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; - }; +export interface IManifestParserCallbacks { + onWarning : (warning : Error) => void; + /** - * "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. + * @param {Function} performRequest - Function performing the request + * @param {TaskCanceller} canceller - Interface allowing to cancel the request + * performed by the `performRequest` argument. + * @returns {Promise.} */ - 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; - }; + scheduleRequest : ( + performRequest : () => Promise< IRequestedData< Document | string > >, + canceller : TaskCanceller + ) => Promise< IRequestedData< Document | string > >; } -/** 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; - }; +/** + * 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 > >, + canceller : TaskCanceller + ) => Promise< IRequestedData< Document | 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 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; } -/** 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; +/** + * 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; } /** @@ -440,75 +374,6 @@ export interface IChunkTimeInfo { time : number; } -/** Result returned by a segment parser when it parsed an initialization segment. */ -export interface ISegmentParserParsedInitSegment { - segmentType : "init"; - /** - * Initialization segment that can be directly pushed to the corresponding - * buffer. - */ - initializationData : DataType; - /** - * Timescale metadata found inside this initialization segment. - * That timescale might be useful when parsing further merdia segments. - */ - initTimescale? : number; - /** - * If set to `true`, some protection information has been found in this - * initialization segment and lead the corresponding `Representation` - * object to be updated with that new information. - * - * In that case, you can re-check any encryption-related information with the - * `Representation` linked to that segment. - * - * In the great majority of cases, this is set to `true` when new content - * protection initialization data to have been encountered. - */ - protectionDataUpdate : boolean; -} - -/** - * Result returned by a segment parser when it parsed a media segment (not an - * initialization segment). - */ -export interface ISegmentParserParsedSegment { - segmentType : "media"; - /** Parsed chunk of data that can be decoded. */ - chunkData : DataType; - /** Time information on this parsed chunk. */ - chunkInfos : IChunkTimeInfo | null; - /** - * time offset, in seconds, to add to the absolute timed data defined in - * `chunkData` to obtain the "real" wanted effective time. - * - * For example: - * If `chunkData` announces (when parsed by the demuxer or decoder) that the - * segment begins at 32 seconds, and `chunkOffset` equals to `4`, then the - * segment should really begin at 36 seconds (32 + 4). - * - * Note that `chunkInfos` needs not to be offseted as it should already - * contain the correct time information. - */ - chunkOffset : number; - /** - * start and end windows for the segment (part of the chunk respectively - * before and after that time will be ignored). - * `undefined` when their is no such limitation. - */ - appendWindow : [ number | undefined, - number | undefined ]; - /** - * If set and not empty, then "events" have been encountered in this parsed - * chunks. - */ - inbandEvents? : IInbandEvent[]; // Inband events parsed from segment data - /** - * If set to `true`, then parsing this chunk revealed that the current - * Manifest instance needs to be refreshed. - */ - needsManifestRefresh?: boolean; -} - /** Text track segment data, once parsed. */ export interface ITextTrackSegmentData { /** The text track data, in the format indicated in `type`. */ @@ -529,18 +394,64 @@ export interface ITextTrackSegmentData { /** 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"; + 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") +} + +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; +} + +export interface ITransportTextSegmentPipeline { + loadSegment : ISegmentLoader; + parseSegment : ISegmentParser; } +export interface ITransportImageSegmentPipeline { + loadSegment : ISegmentLoader; + parseSegment : ISegmentParser; +} + +export type ITransportSegmentPipeline = ITransportAudioVideoSegmentPipeline | + ITransportTextSegmentPipeline | + ITransportImageSegmentPipeline; + +export type ITransportPipeline = ITransportManifestPipeline | + ITransportSegmentPipeline; + interface IServerSyncInfos { serverTimestamp : number; clientTime : number; } @@ -594,7 +505,7 @@ export type CustomManifestLoader = ( url : string | undefined, // second argument: callbacks - callbacks : { resolve : (args : { data : ILoadedManifest; + callbacks : { resolve : (args : { data : ILoadedManifestFormat; sendingTime? : number; receivingTime? : number; size? : number; @@ -606,3 +517,244 @@ export type CustomManifestLoader = ( ) => // 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; +} + +export interface ISegmentLoaderCallbacks { + /** + * 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. + */ + onProgress : (info : ISegmentLoadingProgressInformation) => void; + /** + * 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. + * + * In that case, this callback might be called multiple times for subsequent + * decodable chunks until the Promise resolves. + * + * 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 cases, the segment data can be retrieved in the Promise + * returned by the segment loader. + */ + onNewChunk : (data : T) => void; +} + +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; +} + +/** + * 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; +} + +/** + * Result returned by a segment loader when a segment has been loaded + * by performing a request. + */ +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; + /** + * "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; +} + +/** Format of a loaded Manifest before parsing. */ +export type ILoadedManifestFormat = Document | + string | + 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"; + /** + * Initialization segment that can be directly pushed to the corresponding + * buffer. + */ + initializationData : DataType | null; + /** + * Timescale metadata found inside this initialization segment. + * That timescale might be useful when parsing further merdia segments. + */ + initTimescale? : number; + /** + * If set to `true`, some protection information has been found in this + * initialization segment and lead the corresponding `Representation` + * object to be updated with that new information. + * + * In that case, you can re-check any encryption-related information with the + * `Representation` linked to that segment. + * + * In the great majority of cases, this is set to `true` when new content + * protection initialization data to have been encountered. + */ + protectionDataUpdate : boolean; +} + +/** + * 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 | null; + /** Time information on this parsed chunk. */ + chunkInfos : IChunkTimeInfo | null; + /** + * time offset, in seconds, to add to the absolute timed data defined in + * `chunkData` to obtain the "real" wanted effective time. + * + * For example: + * If `chunkData` announces (when parsed by the demuxer or decoder) that the + * segment begins at 32 seconds, and `chunkOffset` equals to `4`, then the + * segment should really begin at 36 seconds (32 + 4). + * + * Note that `chunkInfos` needs not to be offseted as it should already + * contain the correct time information. + */ + chunkOffset : number; + /** + * start and end windows for the segment (part of the chunk respectively + * before and after that time will be ignored). + * `undefined` when their is no such limitation. + */ + appendWindow : [ number | undefined, + number | undefined ]; + /** + * If set and not empty, then "events" have been encountered in this parsed + * chunks. + */ + inbandEvents? : IInbandEvent[]; // Inband events parsed from segment data + /** + * If set to `true`, then parsing this chunk revealed that the current + * Manifest instance needs to be refreshed. + */ + needsManifestRefresh?: boolean; +} + +/** 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; + /** + * "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. + */ + 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; +} diff --git a/src/transports/utils/call_custom_manifest_loader.ts b/src/transports/utils/call_custom_manifest_loader.ts index 78ba4510b3..5259f3b9d0 100644 --- a/src/transports/utils/call_custom_manifest_loader.ts +++ b/src/transports/utils/call_custom_manifest_loader.ts @@ -14,27 +14,33 @@ * limitations under the License. */ +import PPromise from "pinkie"; import { - Observable, - Observer, -} from "rxjs"; + 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(); let hasFinished = false; let hasFallbacked = false; @@ -43,30 +49,30 @@ export default function callCustomManifestLoader( * 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 : + hasFinished = true; + if (hasFallbacked || cancelSignal.isCancelled) { + return; + } + 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 }); }; /** @@ -74,9 +80,10 @@ export default function callCustomManifestLoader( * @param {*} err - The corresponding error encountered */ const reject = (err : unknown) : void => { - if (!hasFallbacked) { - hasFinished = true; - obs.error(err); + hasFinished = true; + if (!hasFallbacked && !cancelSignal.isCancelled) { + cancelSignal.deregister(abortCustomLoader); + rej(err); } }; @@ -86,17 +93,30 @@ export default function callCustomManifestLoader( */ const fallback = () => { hasFallbacked = true; - fallbackManifestLoader(args).subscribe(obs); + if (!cancelSignal.isCancelled) { + 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 (hasFallbacked || hasFinished) { + return; + } + if (typeof abort === "function") { abort(); } - }; + rej(err); + } }); }; } 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/transports/utils/text_manifest_loader.ts b/src/transports/utils/text_manifest_loader.ts index 9e040f3dbd..955e6d87fd 100644 --- a/src/transports/utils/text_manifest_loader.ts +++ b/src/transports/utils/text_manifest_loader.ts @@ -14,13 +14,12 @@ * limitations under the License. */ -import { Observable } from "rxjs"; -import isNullOrUndefined from "../../utils/is_null_or_undefined"; 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"; @@ -30,12 +29,13 @@ import callCustomManifestLoader from "./call_custom_manifest_loader"; * @returns {Observable} */ 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 }); } /** @@ -45,10 +45,12 @@ function regularManifestLoader( */ export default function generateManifestLoader( { customManifestLoader } : { customManifestLoader?: CustomManifestLoader } -) : (x : IManifestLoaderArguments) => Observable< IManifestLoaderEvent > { - if (isNullOrUndefined(customManifestLoader)) { - return regularManifestLoader; - } - return callCustomManifestLoader(customManifestLoader, - regularManifestLoader); +) : ( + url : string | undefined, + cancelSignal : CancellationSignal + ) => Promise> +{ + return typeof customManifestLoader !== "function" ? + regularManifestLoader : + callCustomManifestLoader(customManifestLoader, regularManifestLoader); } 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/request/fetch.ts b/src/utils/request/fetch.ts index 651de3139d..ac8efa9473 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,35 +22,35 @@ 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"; + +export interface IFetchedStreamComplete { + duration : number; + receivedTime : number; + sendingTime : number; + size : number; + status : number; + url : string; } -export interface IDataComplete { - type : "data-complete"; - value : { - duration : number; - receivedTime : number; - sendingTime : number; - size : number; - status : number; - url : string; - }; +export interface IFetchedDataObject { + currentTime : number; + duration : number; + chunkSize : number; + size : number; + sendingTime : number; + url : string; + totalSize? : number; + chunk: ArrayBuffer; } export interface IFetchOptions { url : string; + onData : (data : IFetchedDataObject) => void; + cancelSignal : CancellationSignal; headers? : { [ header: string ] : string }|null; timeout? : number; } @@ -70,9 +68,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 +85,112 @@ 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); + } + log.warn("Fetch: Request Error", err instanceof Error ? err.toString() : + ""); + throw new RequestError(options.url, 0, NetworkErrorTypes.ERROR_EVENT); }); } @@ -220,5 +203,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..0f9cb96313 --- /dev/null +++ b/src/utils/task_canceller.ts @@ -0,0 +1,314 @@ +/** + * 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. + */ + public cancel() : void { + if (this.isUsed) { + return ; + } + this.isUsed = true; + const cancellationError = 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 + * Function called when the corresponding `TaskCanceller` is triggered. + * This function should perform all logic allowing to cancel the current task. + */ + 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); + } + }); + } + + /** + * Register the function that will be called in case of the current task is + * cancelled. + * + * @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 the current task has ended without cancellation. + */ + 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 6dd2d2e00a..3207a8b7f8 100644 --- a/tests/integration/scenarios/initial_playback.js +++ b/tests/integration/scenarios/initial_playback.js @@ -221,16 +221,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); });