rx-player
Version:
Canal+ HTML5 Video Player
354 lines (332 loc) • 11.6 kB
text/typescript
import config from "../../../config";
import { formatError } from "../../../errors";
import log from "../../../log";
import type { ISegment, IThumbnailTrack } from "../../../manifest";
import type { ICdnMetadata } from "../../../parsers/manifest";
import type { IPeriod } from "../../../public_types";
import type {
IRequestedData,
IThumbnailLoader,
IThumbnailLoaderOptions,
IThumbnailPipeline,
IThumbnailResponse,
} from "../../../transports";
import arrayFind from "../../../utils/array_find";
import objectAssign from "../../../utils/object_assign";
import type { CancellationSignal } from "../../../utils/task_canceller";
import TaskCanceller, { CancellationError } from "../../../utils/task_canceller";
import type CdnPrioritizer from "../cdn_prioritizer";
import errorSelector from "../utils/error_selector";
import { scheduleRequestWithCdns } from "../utils/schedule_request";
/**
* Create an `IThumbnailFetcher` object which will allow to easily fetch and parse
* segments.
* An `IThumbnailFetcher` also implements a retry mechanism, based on the given
* `requestOptions` argument, which may retry a segment request when it fails.
*
* @param {Object} pipeline
* @param {Object|null} cdnPrioritizer
* @returns {Function}
*/
export default function createThumbnailFetcher(
/** The transport-specific logic allowing to load thumbnails. */
pipeline: IThumbnailPipeline,
/**
* Abstraction allowing to synchronize, update and keep track of the
* priorization of the CDN to use to load any given segment, in cases where
* multiple ones are available.
*
* Can be set to `null` in which case a minimal priorization logic will be used
* instead.
*/
cdnPrioritizer: CdnPrioritizer | null,
// TODO CMCD?
): IThumbnailFetcher {
const { loadThumbnail } = pipeline;
interface IPendingThumbnailRequestInfo {
/** Promise behind the thumbnail request. */
promise: Promise<IRequestedData<ArrayBuffer>>;
/**
* Multiple caller might share the same request promise.
* This reference counter keeps track of the number of caller that are
* currently waiting for that promise to be fulfilled.
* If reaching `0` before the promise resolves or rejects, we know we can
* cancel the request.
*/
referenceCount: number;
/** Information linked to that thumbnail segment. */
thumbnailContext: {
segment: ISegment;
track: IThumbnailTrack;
period: IPeriod;
};
}
// We store information on pending requests, as often the same thumbnail is
// requested many times in a row (due to e.g. the mouse cursor rapidly moving
// on the seek bar).
// So `pendingRequestsInfo` contains metadata on the pending thumbnail request
// if one or else `null`.
const pendingRequestsInfo: IPendingThumbnailRequestInfo[] = [];
/**
* Fetch a specific thumbnail.
* @param {Object} thumbnailContext
* @param {Object} cancellationSignal
* @returns {Promise}
*/
return async function fetchThumbnail(
thumbnailContext: {
segment: ISegment;
track: IThumbnailTrack;
period: IPeriod;
},
cancellationSignal: CancellationSignal,
): Promise<IThumbnailResponse> {
cancellationSignal.register(onCancellation);
const { segment: thumbnail, track: thumbnailTrack } = thumbnailContext;
let currRequestInfo: IPendingThumbnailRequestInfo;
// First check if we're not requesting again the last thumbnail
const pendingInfo = arrayFind(pendingRequestsInfo, ({ thumbnailContext: pCtxt }) => {
return (
pCtxt.period.id === thumbnailContext.period.id &&
pCtxt.track.id === thumbnailContext.track.id &&
pCtxt.segment.id === thumbnailContext.segment.id
);
});
if (pendingInfo !== undefined) {
log.debug("Thumbnails", "Requesting same thumbnail than the pending one", {
time: thumbnailContext.segment.time,
});
currRequestInfo = pendingInfo;
currRequestInfo.referenceCount++;
// Same thumbnail than the pending one, return it.
let response;
try {
response = await currRequestInfo.promise;
} catch (err) {
cancellationSignal.deregister(onCancellation);
throw err;
}
cancellationSignal.deregister(onCancellation);
if (cancellationSignal.isCancelled()) {
throw cancellationSignal.cancellationError;
}
return parseThumbnailStep(response);
}
// NOTE: For now, as multiple `fetchThumbnail` calls might rely on the
// same request, it is difficult to let different request options
// per call. So this is not enabled yet.
const requestOptions = getThumbnailFetcherRequestOptions({});
let connectionTimeout;
if (
requestOptions.connectionTimeout === undefined ||
requestOptions.connectionTimeout < 0
) {
connectionTimeout = undefined;
} else {
connectionTimeout = requestOptions.connectionTimeout;
}
const pipelineRequestOptions: IThumbnailLoaderOptions = {
timeout:
requestOptions.requestTimeout < 0 ? undefined : requestOptions.requestTimeout,
connectionTimeout,
cmcdPayload: undefined,
};
/**
* `TaskCanceller` linked to the thumbnail request.
* This is different than `cancellationSignal` which is linked to this call,
* as several calls might share the same request.
*/
const requestCanceller = new TaskCanceller("Thumbnail request");
const fetchPromise = fetchThumbnailStep();
currRequestInfo = {
thumbnailContext,
promise: fetchPromise,
referenceCount: 1,
};
pendingRequestsInfo.push(currRequestInfo);
const clearRequestInfo = (): void => {
const currRequestIdx = pendingRequestsInfo.indexOf(currRequestInfo);
if (currRequestIdx >= 0) {
pendingRequestsInfo.splice(currRequestIdx, 1);
}
};
let fetchResult;
try {
fetchResult = await fetchPromise;
} catch (err) {
clearRequestInfo();
cancellationSignal.deregister(onCancellation);
throw err;
}
clearRequestInfo();
cancellationSignal.deregister(onCancellation);
if (cancellationSignal.isCancelled()) {
throw cancellationSignal.cancellationError;
}
return parseThumbnailStep(fetchResult);
async function fetchThumbnailStep(): Promise<IRequestedData<ArrayBuffer>> {
log.debug("Thumbnails", "Beginning thumbnail request", { time: thumbnail.time });
try {
const res = await scheduleRequestWithCdns(
thumbnailTrack.cdnMetadata,
cdnPrioritizer,
callLoaderWithUrl,
objectAssign({ onRetry }, requestOptions),
requestCanceller.signal,
);
log.debug("Thumbnails", "Thumbnail request ended with success", {
time: thumbnail.time,
});
return res;
} catch (err) {
if (err instanceof CancellationError) {
log.debug("Thumbnails", "Thumbnail request aborted", { time: thumbnail.time });
throw err;
}
log.debug("Thumbnails", "Thumbnail request failed", { time: thumbnail.time });
throw errorSelector(err);
}
}
function parseThumbnailStep(
rawResponse: IRequestedData<ArrayBuffer>,
): IThumbnailResponse {
try {
return pipeline.parseThumbnail(rawResponse.responseData, {
thumbnail,
thumbnailTrack,
});
} catch (error) {
throw formatError(error, {
defaultCode: "PIPELINE_PARSE_ERROR",
defaultReason: "Unknown parsing error",
});
}
}
function onCancellation() {
log.debug("Thumbnails", "Thumbnail request cancelled", { time: thumbnail.time });
const requestIdx = pendingRequestsInfo.indexOf(currRequestInfo);
if (requestIdx < 0) {
return;
}
pendingRequestsInfo[requestIdx].referenceCount--;
if (pendingRequestsInfo[requestIdx].referenceCount <= 0) {
requestCanceller.cancel("Thumbnail request aborted");
pendingRequestsInfo.splice(requestIdx, 1);
}
}
/**
* Call a segment loader for the given URL with the right arguments.
* @param {Object|null} cdnMetadata
* @returns {Promise}
*/
function callLoaderWithUrl(
cdnMetadata: ICdnMetadata | null,
): ReturnType<IThumbnailLoader> {
return loadThumbnail(
cdnMetadata,
thumbnail,
pipelineRequestOptions,
requestCanceller.signal,
);
}
/**
* Function called when the function request is retried.
* @param {*} err
*/
function onRetry(err: unknown): void {
const formattedErr = errorSelector(err);
log.warn(
"Thumbnails",
"Thumbnail request retry ",
{
time: thumbnail.time,
},
formattedErr,
);
}
};
}
/**
* Defines the `IThumbnailFetcher` function which allows to load a single segment.
*
* Loaded data is entirely communicated through callbacks present in the
* `callbacks` arguments.
*
* The returned Promise only gives an indication of if the request ended with
* success or on error.
*/
export type IThumbnailFetcher = (
/** Context on the thumbnail you want to load */
thumbnailContext: {
/** Thumbnail "segment". */
segment: ISegment;
/** Metadata on the linked thumbnails track. */
track: IThumbnailTrack;
/** Metadata on the `Period` this thumbnail track is a part of. */
period: IPeriod;
},
/** CancellationSignal allowing to cancel the request. */
cancellationSignal: CancellationSignal,
) => Promise<IThumbnailResponse>;
/** requestOptions allowing to configure an `IThumbnailFetcher`'s behavior. */
export interface IThumbnailFetcherOptions {
/**
* 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...).
*/
maxRetry: number;
/**
* Timeout after which request are aborted and, depending on other requestOptions,
* retried.
* To set to `-1` for no timeout.
*/
requestTimeout: number;
/**
* Connection timeout, in milliseconds, after which the request is canceled
* if the responses headers has not being received.
* Do not set or set to "undefined" to disable it.
*/
connectionTimeout: number | undefined;
}
/**
* @param {Object} baseOptions
* @returns {Object}
*/
function getThumbnailFetcherRequestOptions({
maxRetry,
requestTimeout,
connectionTimeout,
}: {
maxRetry?: number | undefined;
requestTimeout?: number | undefined;
connectionTimeout?: number | undefined;
}): IThumbnailFetcherOptions {
const {
DEFAULT_MAX_THUMBNAIL_REQUESTS_RETRY_ON_ERROR,
DEFAULT_THUMBNAIL_REQUEST_TIMEOUT,
DEFAULT_THUMBNAIL_CONNECTION_TIMEOUT,
INITIAL_BACKOFF_DELAY_BASE,
MAX_BACKOFF_DELAY_BASE,
} = config.getCurrent();
return {
maxRetry: maxRetry ?? DEFAULT_MAX_THUMBNAIL_REQUESTS_RETRY_ON_ERROR,
baseDelay: INITIAL_BACKOFF_DELAY_BASE.REGULAR,
maxDelay: MAX_BACKOFF_DELAY_BASE.REGULAR,
requestTimeout:
requestTimeout === undefined ? DEFAULT_THUMBNAIL_REQUEST_TIMEOUT : requestTimeout,
connectionTimeout:
connectionTimeout === undefined
? DEFAULT_THUMBNAIL_CONNECTION_TIMEOUT
: connectionTimeout,
};
}